package com.edde746.plezy.exoplayer

import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.FilteringMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.mp4.FragmentedMp4Extractor
import androidx.media3.extractor.mp4.Mp4Extractor
import androidx.media3.extractor.ts.TsExtractor
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import com.edde746.plezy.AndroidRuntimeDiagnostics
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong
import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider

interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {

  /**
   * Called when ExoPlayer encounters a format it cannot play.
   * The plugin should handle fallback to MPV.
   * @return true if fallback was handled, false to emit error event to Flutter
   */
  fun onFormatUnsupported(
    mediaGeneration: Int,
    uri: String,
    headers: Map<String, String>?,
    positionMs: Long,
    playWhenReady: Boolean,
    errorMessage: String
  ): Boolean = false
}

internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null

@OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) :
  Player.Listener,
  SurfacePlayerCore {

  companion object {
    private const val TAG = "ExoPlayerCore"

    private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
    private const val WATCHDOG_TIMEOUT_MS = 8000L
    private const val DECODER_HANG_TIMEOUT_MS = 5000L
    private const val MAX_AUDIO_RECOVERY_ATTEMPTS = 2
    private const val MIN_PLAYBACK_SPEED = 0.25f
    private const val MAX_PLAYBACK_SPEED = 8f
    private const val FPS_SAMPLE_COUNT = 8
    private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L

    /** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing
     *  ASS subtitle lag against the libass pipeline's render/swap lines. */
    private const val ASS_FRAME_LOGS = false
    private const val ASS_SYNC_LOG_INTERVAL_FRAMES = 120L

    /** Auto-calibrate the subtitle/video layer offset per device (API 34+) by measuring the
     *  video vs overlay plane present timing. See [AssLatencyCalibrator]. Falls back to the
     *  seeded value (persisted calibration or the Dart perf-tier proxy) when off/unsupported. */
    private const val ASS_LATENCY_AUTOCAL = true

    /** SharedPreferences store for the per-device subtitle/video latency calibration. */
    private const val ASS_CAL_PREFS = "plezy_ass_calibration"
    private const val ASS_CAL_KEY_FRAMES = "video_latency_frames"
    private const val TS_TIMESTAMP_SEARCH_PACKETS = 1800
    private val DV_CODEC_PROFILE_REGEX = Regex("""(?:^|,)\s*dvh[1e]\.(\d{2})""")

    // Codec capability caches — codec support doesn't change at runtime
    private val hwAudioDecoderCache = HashMap<String, Boolean>()
    private val tunneledPlaybackCache = HashMap<String, Boolean>()

    @Volatile private var cronetEngine: CronetEngine? = null

    @Volatile private var cronetUnavailable = false
    private fun getCronetEngine(context: Context): CronetEngine? {
      cronetEngine?.let { return it }
      if (cronetUnavailable) return null
      return synchronized(this) {
        cronetEngine?.let { return@synchronized it }
        if (cronetUnavailable) return@synchronized null

        val providers = try {
          CronetProvider.getAllProviders(context.applicationContext)
            .filter { it.isEnabled && it.name != CronetProvider.PROVIDER_NAME_FALLBACK }
            .sortedBy { if (it.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) 0 else 1 }
        } catch (t: Throwable) {
          Log.w(TAG, "Cronet provider discovery failed", t)
          cronetUnavailable = true
          return@synchronized null
        }

        for (provider in providers) {
          try {
            return@synchronized provider.createBuilder()
              .enableHttp2(true)
              .enableQuic(true)
              .build()
              .also { cronetEngine = it }
          } catch (t: Throwable) {
            Log.w(TAG, "Cronet provider ${provider.name} failed", t)
          }
        }

        cronetUnavailable = true
        null
      }
    }
    private val cronetExecutor by lazy { Executors.newSingleThreadExecutor() }
  }

  private var surfaceView: SurfaceView? = null
  private var surfaceContainer: FrameLayout? = null
  private var videoAspectContainer: AspectRatioFrameLayout? = null
  private var subtitleView: SubtitleView? = null
  private var bitmapSubtitleView: SubtitleView? = null
  private var videoZoomScale: Float = 1.0f
  private var assHandler: AssHandler? = null
  private var assSubtitleView: AssSubtitleSurfaceView? = null

  // Touched from the codec metadata listener, the GL-thread overlay hook, and the main-thread
  // media-item-transition/dispose paths — keep visibility across threads.
  @Volatile private var latencyCalibrator: AssLatencyCalibrator? = null
  private var assForceMargins = false
  private var lastAssMargins: IntArray? = null
  private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
  private var lastVideoSize: VideoSize? = null
  private var exoPlayer: ExoPlayer? = null
  private var renderersFactory: PlezyRenderersFactory? = null
  private val subtitleDelayUs = AtomicLong(0L)

  /**
   * Frames the hardware codec→display path lags a GL subtitle overlay pinned to the
   * same release time (the overlay otherwise shows a frame ahead of the picture).
   * The subtitle is rendered this many frames earlier to match the later video — a
   * content-time shift, so it never delays the overlay's present (delaying the
   * present freezes the single-slot latest-wins pipeline). Device-specific: ~1 on
   * low-end TV boxes (longer video pipeline), 0 on phones. Set from Dart at init
   * from the device performance tier ([com.plezy/device] auto low-end signal).
   */
  @Volatile private var assVideoLatencyFrames = 0
  private var subtitlePositionPercent: Int = 100
  private var subtitleFontSize: Float = 55f
  private var lastSubtitleCues: List<Cue> = emptyList()

  // Tracks whether a text track was selected on the previous onTracksChanged so we
  // can detect the transition to "no subtitle" and clear the painted overlays (#1387).
  private var hadSelectedTextTrack: Boolean = false
  private var httpDataSourceFactory: HttpDataSource.Factory? = null
  private var dataSourceFactory: DefaultDataSource.Factory? = null
  private var trackSelector: DefaultTrackSelector? = null
  private var tunnelingUserEnabled: Boolean = true
  private var tunnelingDisabledForAudioCodec: Boolean = false
  private var tunnelingDisabledForVideoCodec: Boolean = false
  private var tunnelingDisabledForDecodedPcm: Boolean = false
  private var tunnelingDisabledForAudioRecovery: Boolean = false

  // Tunneled playback never fires the VideoFrameMetadataListener (media3 releases
  // frames inside the codec), which is the libass pipeline's only render trigger —
  // ASS subs would freeze. Correctness over tunneling while an ASS track is active.
  private var tunnelingDisabledForAssSubtitles: Boolean = false
  private val tunnelingDisabledForCodec: Boolean
    get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec || tunnelingDisabledForDecodedPcm || tunnelingDisabledForAudioRecovery
  private var currentTunneledPlayback: Boolean = false

  // Loudness normalization (#1289): audiofx effects only process non-tunneled
  // PCM mixer streams, so while enabled we block direct/bitstream output and
  // disable tunneling.
  private var audioPassthroughEnabled: Boolean = false
  private var audioNormalizationEnabled: Boolean = false

  // Stereo downmix: runs as a ChannelMixingAudioProcessor inside the sink's
  // decoded-PCM pipeline, so encoded audio is force-decoded while enabled.
  private var audioDownmixEnabled: Boolean = false
  private var audioDownmixCenterBoostDb: Int = 0
  private var audioDownmixNormalize: Boolean = true
  private val audioNormalization = AudioNormalizationEffect(::emitLog)
  private var pendingAudioRendererBounce: Boolean = false
  private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio renderer bounce timeout") }
  private var lastSeekable: Boolean? = null
  private var forceSeekable: Boolean = false

  @Volatile private var disposing: Boolean = false
  private var pendingStartPositionMs: Long = 0L
  private var pendingPlayWhenReady: Boolean? = null

  // Frame watchdog: detects black screen (audio plays but 0 video frames rendered)
  private var frameWatchdogRunnable: Runnable? = null
  private var frameWatchdogStartTime: Long = 0L

  // Post-resume video stall watchdog (#1454): some TV SoCs (Amlogic Mi Box class)
  // stall the MediaCodec output path after pause→resume — the clock and audio keep
  // advancing but the picture freezes until a seek flushes the codec. Armed on every
  // transition to playing with a warm decoder; recovers with a micro seek-back,
  // capped per session.
  private var resumeStallRunnable: Runnable? = null
  private var resumeStallVerifyRunnable: Runnable? = null
  private var resumeStallBaselineFrames = 0
  private var resumeStallBaselinePositionMs = 0L
  private var resumeStallRechecksLeft = 0
  private var resumeStallRecoveryCount = 0
  private var loggedResumeStallCap = false

  // Decoder hang detection: tracks gap between decoder init and first rendered frame
  private var decoderHangRunnable: Runnable? = null
  private var decoderInitName: String? = null
  private var audioDecoderInitName: String? = null
  private var lastAudioTrackConfig: AudioSink.AudioTrackConfig? = null
  private val directAudioOutputBlockedAfterFailure = mutableSetOf<String>()
  private val loggedDirectAudioRecoveryBlocks = mutableSetOf<String>()
  private var audioRecoveryAttempts: Int = 0
  private var lastAudioRecoveryAction: String? = null
  private var lastAudioRecoveryReason: String? = null
  private var lastAudioSinkError: String? = null
  private var loggedEwasteEac3Workaround: Boolean = false
  private var lastTrueHdDirectOutputLogKey: String? = null
  private var loggedDecodedPcmTunnelingGuard: Boolean = false
  private var hasRenderedVideoFrameForMedia: Boolean = false
  private var videoDecoderRecoveryConsecutiveAttempts: Int = 0
  private var videoDecoderRecoveryTotalAttempts: Int = 0
  private var videoDecoderRecoveryPositionMs: Long? = null
  private var firstFrameRendered: Boolean = false
  var delegate: ExoPlayerDelegate? = null
  var debugLoggingEnabled: Boolean = false
  var isInitialized: Boolean = false
    private set

  // Frame rate matching
  private var frameRateManager: FrameRateManager? = null
  private val handler = Handler(Looper.getMainLooper())

  // FPS detection from frame timestamps (fallback when Format.frameRate is NO_VALUE)
  @Volatile private var detectedFrameRate: Float = -1f
  private val fpsTimestamps = LongArray(FPS_SAMPLE_COUNT)

  @Volatile private var fpsTimestampCount = 0
  private var assSyncFrameCount = 0L

  // Audio focus
  private var audioFocusManager: AudioFocusManager? = null

  // Track state for event emission
  private var lastPosition: Long = 0

  /** Position to use for fallback: max of current position and pending start position. */
  private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs)
  private var lastDuration: Long = 0
  private var lastBufferedPosition: Long = 0
  private var positionUpdateRunnable: Runnable? = null

  // External subtitles added dynamically
  private val externalSubtitles = mutableListOf<MediaItem.SubtitleConfiguration>()
  private val externalSubtitleUris = mutableListOf<String>()
  private val externalSubtitleContainerUris = mutableListOf<String>()
  private var playbackMediaSourceFactory: DefaultMediaSourceFactory? = null
  private var currentMediaUri: String? = null
  private var currentHeaders: Map<String, String>? = null
  private var currentMediaIsLive: Boolean = false
  private var currentMediaGeneration: Int = 0
  private var terminalErrorGeneration: Int? = null
  private var currentVisible: Boolean = false
  private var selectedAudioTrackId: String? = null
  private var selectedSubtitleTrackId: String? = null
  private val audioTrackGroupMap = mutableMapOf<String, TrackGroup>()
  private val subtitleTrackGroupMap = mutableMapOf<String, TrackGroup>()
  private var pendingTrackRestore: PendingTrackRestore? = null

  private data class PendingTrackRestore(
    val audio: TrackRestoreIdentity?,
    val subtitle: TrackRestoreIdentity?,
    val subtitleDisabled: Boolean
  )

  private data class TrackRestoreIdentity(
    val type: Int,
    val groupIndex: Int,
    val trackIndex: Int,
    val formatId: String?,
    val label: String?,
    val language: String?,
    val sampleMimeType: String?,
    val codecs: String?,
    val channelCount: Int,
    val sampleRate: Int,
    val selectionFlags: Int
  )

  private data class TrackRestoreMatch(
    val group: Tracks.Group,
    val trackGroup: TrackGroup,
    val trackIndex: Int,
    val format: Format,
    val score: Int
  )

  private data class DvPlaybackInfo(
    val sourceProfile: Int,
    val path: String,
    val reason: String,
    val decoder: String
  )

  private fun emitLog(level: String, prefix: String, message: String) {
    when (level) {
      "error" -> Log.e(TAG, "[$prefix] $message")
      "warn" -> Log.w(TAG, "[$prefix] $message")
      "info" -> Log.i(TAG, "[$prefix] $message")
      else -> Log.d(TAG, "[$prefix] $message")
    }
    if (debugLoggingEnabled || level == "error" || level == "warn") {
      delegate?.onEvent(
        "log-message",
        mapOf(
          "prefix" to prefix,
          "level" to level,
          "text" to message
        )
      )
    }
  }
  private fun emitPlaybackErrorOnce(mediaGeneration: Int, message: String, cause: String? = null) {
    if (mediaGeneration != currentMediaGeneration || terminalErrorGeneration == mediaGeneration) return
    terminalErrorGeneration = mediaGeneration
    exoPlayer?.playWhenReady = false
    delegate?.onPropertyChange("paused-for-cache", false)
    delegate?.onPropertyChange("pause", true)
    val data = mutableMapOf<String, Any>(
      "reason" to "error",
      "message" to message
    )
    if (cause != null) data["cause"] = cause
    delegate?.onEvent("end-file", data)
  }

  private fun requestFormatFallback(
    mediaGeneration: Int,
    uri: String,
    positionMs: Long,
    playWhenReady: Boolean,
    errorMessage: String
  ): Boolean {
    if (mediaGeneration != currentMediaGeneration) return true
    val handled = delegate?.onFormatUnsupported(
      mediaGeneration = mediaGeneration,
      uri = uri,
      headers = currentHeaders,
      positionMs = positionMs,
      playWhenReady = playWhenReady,
      errorMessage = errorMessage
    ) ?: false
    if (!handled) emitPlaybackErrorOnce(mediaGeneration, errorMessage)
    return handled
  }

  private fun redactUri(uri: String): String {
    return try {
      val parsed = Uri.parse(uri)
      val params = parsed.queryParameterNames
      if (params.isEmpty()) return uri
      val builder = parsed.buildUpon().clearQuery()
      for (name in params) {
        val lower = name.lowercase()
        if (lower.contains("token") || lower.contains("key") || lower.contains("auth")) {
          builder.appendQueryParameter(name, "[REDACTED]")
        } else {
          builder.appendQueryParameter(name, parsed.getQueryParameter(name))
        }
      }
      builder.build().toString()
    } catch (_: Exception) {
      uri
    }
  }

  private fun ensureFlutterOverlayOnTop() {
    if (disposing) return
    val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
    contentView.post {
      if (disposing || !isInitialized) return@post
      PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
    }
  }

  // DV conversion state
  private var dvMode: DvConversionMode = DvConversionMode.DISABLED
  private var debugDvModeOverride: DvConversionMode? = null
  private var dv7RetryAttempted = false
  private var currentVideoFormat: Format? = null
  private var loggedNativeDvSelectionKey: String? = null
  private var loggedNativeDvFirstFrame = false
  private var loggedDvPlaybackPathKey: String? = null
  private var lastDvPlaybackInfo: DvPlaybackInfo? = null

  @Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null

  @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null

  private fun getConfiguredDvMode(): DvConversionMode {
    val override = debugDvModeOverride
    if (override != null) return override

    val decision = DoviBridge.getConversionDecision(activity)
    emitLog("info", "dv-auto", decision.logMessage())
    return decision.mode
  }

  fun initialize(
    bufferSizeBytes: Int? = null,
    tunnelingEnabled: Boolean = true,
    audioPassthroughEnabled: Boolean = false
  ): Boolean {
    if (isInitialized) {
      Log.d(TAG, "Already initialized")
      return true
    }

    tunnelingUserEnabled = tunnelingEnabled
    this.audioPassthroughEnabled = audioPassthroughEnabled
    this.dvMode = getConfiguredDvMode()
    DoviBridge.logSupportSummary(activity)
    Log.i(
      TAG,
      "DV conversion: mode=$dvMode, override=${debugDvModeOverride?.name ?: "AUTO"}"
    )
    disposing = false

    try {
      audioFocusManager = AudioFocusManager(
        context = activity,
        handler = handler,
        onPause = { if (isInitialized) exoPlayer?.pause() },
        onResume = { if (isInitialized) exoPlayer?.play() },
        isPaused = { exoPlayer?.isPlaying != true },
        log = { emitLog("debug", "audio", it) }
      )
      frameRateManager = FrameRateManager(
        activity = activity,
        handler = handler,
        log = { emitLog("info", "framerate", it) }
      )

      // Create FrameLayout container for video (clips overflow for ZOOM crop mode).
      surfaceContainer = PlayerSurfaceHost.createContainer(activity, clipChildren = true)

      // AspectRatioFrameLayout drives FIT/ZOOM/FILL via Media3's resizeMode.
      // Centered inside the container; in ZOOM mode it measures larger than
      // the container and the parent's clipChildren crops the overflow.
      videoAspectContainer = AspectRatioFrameLayout(activity).apply {
        layoutParams = FrameLayout.LayoutParams(
          FrameLayout.LayoutParams.MATCH_PARENT,
          FrameLayout.LayoutParams.MATCH_PARENT
        ).apply {
          gravity = Gravity.CENTER
        }
        resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
      }

      // Create SurfaceView for video rendering (fills the ARFL).
      surfaceView = PlayerSurfaceHost.createVideoSurface(activity, surfaceCallback)

      videoAspectContainer!!.addView(surfaceView)
      surfaceContainer!!.addView(videoAspectContainer)

      // Separate bitmap subtitles from text subtitles. Media3 scales PGS/VOB
      // bitmap cue width and height against the SubtitleView bounds, so putting
      // image cues in the screen-sized text view deforms them on stretch/zoom.
      bitmapSubtitleView = SubtitleView(activity).apply {
        layoutParams = FrameLayout.LayoutParams(
          FrameLayout.LayoutParams.MATCH_PARENT,
          FrameLayout.LayoutParams.MATCH_PARENT
        )
      }

      // Create SubtitleView - added to surfaceContainer above video. Hosts only
      // the built-in CanvasSubtitleOutput for non-ASS text cues; the ASS overlay
      // is screen-sized and tracks the video rect via libass margins.
      subtitleView = SubtitleView(activity).apply {
        layoutParams = FrameLayout.LayoutParams(
          FrameLayout.LayoutParams.MATCH_PARENT,
          FrameLayout.LayoutParams.MATCH_PARENT
        )
      }
      // Add SubtitleView to surfaceContainer (above video SurfaceView)
      // Flutter renders on top of entire surfaceContainer, keeping subtitles below UI
      surfaceContainer!!.addView(bitmapSubtitleView)
      surfaceContainer!!.addView(subtitleView)
      Log.d(TAG, "SubtitleViews created and added to surfaceContainer")

      val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)

      ensureFlutterOverlayOnTop()
      overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
        ensureFlutterOverlayOnTop()
        // Recalculate surface size on layout change (orientation/PiP transitions)
        lastVideoSize?.let { vs ->
          if (vs.width > 0 && vs.height > 0) {
            updateSurfaceViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
          }
        }
      }
      contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)

      Log.d(TAG, "SurfaceView added to content view")

      // Create track selector with text tracks enabled
      trackSelector = DefaultTrackSelector(activity).apply {
        setParameters(
          buildUponParameters()
            // Recover passthrough when HDMI capabilities flap (Shield refresh-rate / AVR link drop):
            // the sink temporarily falls back to PCM, and this flag lets the selector re-pick the
            // encoded audio track when capabilities come back. See androidx/media#2258.
            .setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
            .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
        )
      }

      // Create ExoPlayer with FFmpeg audio decoder fallback
      val audioAttributes = buildMovieAudioAttributes()

      // Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs.
      val renderersFactory = PlezyRenderersFactory(activity).apply {
        audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
        videoDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
        shouldBlockDirectAudioOutput = { format -> this@ExoPlayerCore.shouldBlockDirectAudioOutput(format, "sink support") }
        onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") }
        setEnableDecoderFallback(true)
        setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
        setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
          if (shouldForceAppAudioDecoder(mimeType)) {
            emptyList()
          } else {
            MediaCodecSelector.DEFAULT.getDecoderInfos(
              mimeType,
              requiresSecureDecoder,
              requiresTunnelingDecoder
            )
          }
        }
      }
      this.renderersFactory = renderersFactory
      updateAudioDecoderPolicy("initialize")

      // Prefer Cronet for HTTP/2 multiplexing; fall back when a device's provider crashes during init.
      val cronetEngine = getCronetEngine(activity)
      val dataSourceLabel: String
      val httpFactory: HttpDataSource.Factory
      if (cronetEngine != null) {
        dataSourceLabel = "Cronet"
        httpFactory = CronetDataSource.Factory(cronetEngine, cronetExecutor)
          .setConnectionTimeoutMs(15_000)
          .setReadTimeoutMs(10_000)
      } else {
        dataSourceLabel = "DefaultHttp"
        httpFactory = DefaultHttpDataSource.Factory()
          .setConnectTimeoutMs(15_000)
          .setReadTimeoutMs(10_000)
      }
      httpDataSourceFactory = httpFactory
      dataSourceFactory = DefaultDataSource.Factory(activity, httpFactory)
      val extractorsFactory = DefaultExtractorsFactory()
        // High-bitrate Plex DVR MPEG-TS recordings can have sparse PCR packets; the default
        // 600-packet window may leave duration unknown and seeking disabled.
        .setTsExtractorTimestampSearchBytes(TS_TIMESTAMP_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE)

      // Inline buildWithAssSupport to retain AssHandler reference for font scale control.
      val handler = AssHandler()
      assHandler = handler

      val assParserFactory = AssSubtitleParserFactory(handler)

      // Wrap extractors: replace MatroskaExtractor with ASS+DV variant,
      // wrap MP4 extractors with DV converter when enabled.
      // Reads this.dvMode each time (not captured) so DV7→8.1 retry can
      // change mode and reload without reinitializing the player.
      val wrappedExtractorsFactory = androidx.media3.extractor.ExtractorsFactory {
        val currentDvMode = this.dvMode
        val doviEnabled = currentDvMode != DvConversionMode.DISABLED
        extractorsFactory.createExtractors().map { extractor ->
          when {
            extractor is MatroskaExtractor -> {
              val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler)
              val inner = if (doviEnabled) {
                DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
                  emitLog(level, prefix, message)
                }.also {
                  activeDoviMkvWrapper = it
                }
              } else {
                assExtractor
              }
              // Wrap with approximate seeking for MKV files without Cues
              CuelessSeekExtractorWrapper(inner)
            }
            doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
              DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
                emitLog(level, prefix, message)
              }.also {
                activeDoviMp4Wrapper = it
              }
            }
            else -> extractor
          }
        }.toTypedArray()
      }

      val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory!!, wrappedExtractorsFactory)
        .setSubtitleParserFactory(assParserFactory)
      playbackMediaSourceFactory = mediaSourceFactory

      // Wrap text renderers with subtitle delay support
      val wrappedRenderersFactory = RenderersFactory { eventHandler, videoListener, audioListener, textOutput, metadataOutput ->
        renderersFactory.createRenderers(eventHandler, videoListener, audioListener, textOutput, metadataOutput)
          .map { if (it.trackType == C.TRACK_TYPE_TEXT) SubtitleDelayRenderer(it, subtitleDelayUs) else it }
          .toTypedArray()
      }

      // Compute memory-aware buffer limits to prevent CCodec OOM crashes
      val activityManager = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
      val memoryInfo = ActivityManager.MemoryInfo()
      activityManager.getMemoryInfo(memoryInfo)
      val availableMB = memoryInfo.availMem / (1024 * 1024)

      val targetBufferBytes = if (bufferSizeBytes != null && bufferSizeBytes > 0) {
        bufferSizeBytes
      } else {
        // Scale buffer to available memory to reduce hardware decoder pressure.
        // Larger buffers reduce oscillation frequency at high bitrates (50-100Mbps).
        when {
          availableMB <= 512 -> 30 * 1024 * 1024
          availableMB <= 1024 -> 80 * 1024 * 1024
          availableMB <= 2048 -> 120 * 1024 * 1024
          else -> 200 * 1024 * 1024
        }
      }

      val loadControl = DefaultLoadControl.Builder().apply {
        setTargetBufferBytes(targetBufferBytes)
        setPrioritizeTimeOverSizeThresholds(false)
        if (availableMB <= 2048) {
          setBufferDurationsMs(15_000, 50_000, 1_000, 5_000)
        } else {
          setBufferDurationsMs(30_000, 60_000, 1_000, 5_000)
        }
      }.build()
      emitLog("info", "init", "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB, tunneling=$tunnelingUserEnabled, dataSource=$dataSourceLabel")

      exoPlayer = ExoPlayer.Builder(activity)
        .setTrackSelector(trackSelector!!)
        .setLoadControl(loadControl)
        .setAudioAttributes(audioAttributes, false) // We handle audio focus manually
        .setMediaSourceFactory(mediaSourceFactory)
        .setRenderersFactory(wrappedRenderersFactory)
        .build()

      // Add ASS overlay view to the full-screen surfaceContainer (NOT the zoom-scaled
      // videoAspectContainer): the libass frame = screen, and mpv-style ass_set_margins
      // describe where the video dst rect sits inside it (negative when zoomed past the
      // edges) — see updateAssMargins(). Non-positioned dialogue can then be forced
      // on-screen (sub-ass-force-margins) while positioned/typeset events stay glued to
      // the video rect, matching mpv. Also keeps the subtitle surface unscaled (crisp
      // text, no per-gesture geometry churn).
      // AssSubtitleSurfaceView gives us a SurfaceFlinger-layer-backed overlay that
      // eglPresentationTimeANDROID can vsync-pin to the video frame.
      // Z-order: video SurfaceView (-2) < this MediaOverlay-flagged
      // SurfaceView (-1) < parent canvas < Flutter SurfaceView (+1) in the window.
      // Inserted before the Media3 subtitle views so both punches run before
      // PGS/VOB/SRT/VTT cues draw on the parent canvas.
      surfaceContainer?.let { container ->
        val assView = AssSubtitleSurfaceView(container.context, handler)
        assSubtitleView = assView
        // Pre-36 sublayer is already set by the view's own setZOrderMediaOverlay(true).
        FlutterOverlayHelper.applyCompositionOrder(assView, -1)
        val bitmapIndex = container.indexOfChild(bitmapSubtitleView)
        val textIndex = container.indexOfChild(subtitleView)
        val subtitleIndex = when {
          bitmapIndex >= 0 && textIndex >= 0 -> minOf(bitmapIndex, textIndex)
          bitmapIndex >= 0 -> bitmapIndex
          textIndex >= 0 -> textIndex
          else -> -1
        }
        container.addView(
          assView,
          if (subtitleIndex >= 0) subtitleIndex else container.childCount,
          FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.MATCH_PARENT,
            FrameLayout.LayoutParams.MATCH_PARENT
          )
        )
      }

      // Initialize handler (registers as Player.Listener, creates Handler).
      // AssHandler.init calls player.setVideoFrameMetadataListener internally, but
      // our own setVideoFrameMetadataListener below would overwrite it (ExoPlayer
      // only keeps one listener). Skip AssHandler's wiring and invoke
      // assView.requestRender directly from the listener below.
      handler.init(exoPlayer!!)

      exoPlayer!!.addListener(this)
      exoPlayer!!.addAnalyticsListener(decoderHangListener)
      exoPlayer!!.setVideoFrameMetadataListener { presentationTimeUs, releaseTimeNs, _, _ ->
        // ASS bypasses Media3's text renderer, so apply sub-delay before libass renders.
        // Also render the subtitle one video-frame earlier than the picture: the
        // hardware codec→display path lags a GL overlay pinned to the same release
        // time, so without this the overlay shows a frame ahead of the video. Done
        // as a content-time shift (not a present delay, which would freeze the
        // single-slot latest-wins pipeline by superseding every frame).
        val assView = assSubtitleView
        val latencyFrames = assVideoLatencyFrames
        val fps = detectedFrameRate.takeIf { it > 1f } ?: currentVideoFormat?.frameRate?.takeIf { it > 1f } ?: 0f
        val videoLatencyUs = if (latencyFrames != 0 && fps > 1f) {
          (latencyFrames * 1_000_000.0 / fps).toLong()
        } else {
          0L
        }
        assView?.requestRender(presentationTimeUs - subtitleDelayUs.get() - videoLatencyUs, releaseTimeNs)
        if (ASS_LATENCY_AUTOCAL && assView != null && Build.VERSION.SDK_INT >= 34) {
          val calibrator = latencyCalibrator ?: surfaceView?.let { sv ->
            AssLatencyCalibrator(
              videoSurface = sv,
              overlaySurface = assView,
              onCalibrated = { frames -> onAssLatencyCalibrated(frames) },
              onDone = { assSubtitleView?.setPreSwapProbe(null) },
              log = { msg -> emitLog("info", "ass-latency-cal", msg) }
            ).also {
              it.start()
              // Bind the hook to THIS instance, not the volatile field, so a concurrent transition
              // reset can't redirect it to a different/null calibrator mid-swap.
              assView.setPreSwapProbe { rt -> it.probeOverlay(rt) }
              latencyCalibrator = it
            }
          }
          calibrator?.probeVideo(releaseTimeNs, fps)
        }
        assSyncFrameCount++
        if (assView != null && assSyncFrameCount % ASS_SYNC_LOG_INTERVAL_FRAMES == 0L) {
          emitLog(
            "info",
            "ass-sync",
            "frames=$assSyncFrameCount swaps=${assView.swapCount} late=${assView.lateSwapCount} " +
              "phaseLeadMs=${assView.phaseLeadMs} swapLeadMs=${assView.swapLeadMs} frameOffMs=${videoLatencyUs / 1000} sleepMs=${assView.lastScheduledSleepMs} " +
              "headroomMs=${assView.lastSwapHeadroomMs} leadMs=${assView.lastSwapLeadMs} " +
              "minLeadMs=${assView.minLeadChangedMs?.toString() ?: "n/a"} " +
              "present=${assView.presentSource} " +
              "presentErrMs=${assView.lastPresentErrorMs?.toString() ?: "n/a"} " +
              "worstPresentMs=${assView.worstPresentErrorMs?.toString() ?: "n/a"} " +
              "presentHist=${assView.presentErrorHistogram.joinToString(",")} " +
              "presentMeasured=${assView.presentMeasuredCount} " +
              "presentInvalid=${assView.presentInvalidCount} presentDropped=${assView.presentDroppedCount} " +
              "render=${assView.changedRenderCount}/${assView.renderCount} " +
              "libassMs=${assView.lastLibassMs}/${assView.maxLibassMs} libassHist=${assView.libassMsHistogram.joinToString(",")} " +
              "spec=${assView.specHits}/${assView.specMisses}/${assView.specSkips} " +
              "prefetch=${assView.prefetchCount} blankClears=${assView.blankClearCount} " +
              "coalesced=${assView.coalescedRequestCount} stale=${assView.staleGenerationCount}/${assView.staleBeforeSwapCount} " +
              "superseded=${assView.supersededBeforeSwapCount}"
          )
        }
        if (ASS_FRAME_LOGS) {
          // Reference stream for subtitle-lag diagnosis: the video frame ExoPlayer
          // is releasing right now and how far ahead of its vsync we are. Subtitle
          // "render pts=" lines lagging these pts values = pipeline behind;
          // budgetMs far from ~10-50 = release-time clock-domain trouble.
          val budgetMs = (releaseTimeNs - System.nanoTime()) / 1_000_000
          Log.d(
            "AssFrameCb",
            "video pts=${presentationTimeUs / 1000}ms budgetMs=$budgetMs" +
              (subtitleDelayUs.get().takeIf { it != 0L }?.let { " subDelayMs=${it / 1000}" } ?: "")
          )
        }
        val count = fpsTimestampCount
        if (count < FPS_SAMPLE_COUNT) {
          fpsTimestamps[count] = presentationTimeUs
          fpsTimestampCount = count + 1
          if (count + 1 == FPS_SAMPLE_COUNT) {
            detectedFrameRate = computeFrameRate(fpsTimestamps)
            Log.d(TAG, "Detected frame rate: $detectedFrameRate fps")
          }
        }
      }
      surfaceView?.let { exoPlayer!!.setVideoSurfaceView(it) }

      // Debug: Log SubtitleView child hierarchy
      subtitleView?.post {
        Log.d(TAG, "Text SubtitleView post-layout: width=${subtitleView?.width}, height=${subtitleView?.height}, childCount=${subtitleView?.childCount}")
        for (i in 0 until (subtitleView?.childCount ?: 0)) {
          val child = subtitleView?.getChildAt(i)
          Log.d(TAG, "  Child $i: ${child?.javaClass?.simpleName}, w=${child?.width}, h=${child?.height}, visibility=${child?.visibility}")
        }
      }
      bitmapSubtitleView?.post {
        Log.d(TAG, "Bitmap SubtitleView post-layout: width=${bitmapSubtitleView?.width}, height=${bitmapSubtitleView?.height}, childCount=${bitmapSubtitleView?.childCount}")
      }

      // Start position update loop
      startPositionUpdates()

      isInitialized = true
      Log.d(TAG, "Initialized successfully")
      return true
    } catch (e: Exception) {
      Log.e(TAG, "Failed to initialize: ${e.message}", e)
      try {
        dispose()
      } catch (cleanupError: Exception) {
        Log.e(TAG, "Failed to clean up partial initialization", cleanupError)
      }
      return false
    }
  }

  private val surfaceCallback = object : android.view.SurfaceHolder.Callback {
    override fun surfaceCreated(holder: android.view.SurfaceHolder) {
      if (disposing) return
      emitLog("debug", "surface", "Created")
      ensureFlutterOverlayOnTop()
    }

    override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, width: Int, height: Int) {
      emitLog("debug", "surface", "Changed: ${width}x$height")
    }

    override fun surfaceDestroyed(holder: android.view.SurfaceHolder) {
      emitLog("debug", "surface", "Destroyed")
    }
  }

  private fun startPositionUpdates() {
    positionUpdateRunnable = object : Runnable {
      override fun run() {
        if (isInitialized && exoPlayer != null) {
          val player = exoPlayer!!
          val currentPosition = player.currentPosition
          val duration = player.duration
          val bufferedPosition = player.bufferedPosition
          updateVideoDecoderRecoveryHealth(currentPosition, player.isPlaying)

          // Emit position changes (every 250ms update)
          if (currentPosition != lastPosition) {
            lastPosition = currentPosition
            delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
          }

          // Emit duration changes
          if (duration != lastDuration && duration != C.TIME_UNSET) {
            lastDuration = duration
            delegate?.onPropertyChange("duration", duration / 1000.0)
          }

          // Emit buffer changes
          if (bufferedPosition != lastBufferedPosition && bufferedPosition != C.TIME_UNSET) {
            lastBufferedPosition = bufferedPosition
            delegate?.onPropertyChange("demuxer-cache-time", bufferedPosition / 1000.0)
          }

          handler.postDelayed(this, 250)
        }
      }
    }
    handler.post(positionUpdateRunnable!!)
  }

  private fun stopPositionUpdates() {
    positionUpdateRunnable?.let { handler.removeCallbacks(it) }
    positionUpdateRunnable = null
  }

  private fun resetPlaybackProgress(startPositionMs: Long) {
    lastPosition = startPositionMs
    lastDuration = 0L
    lastBufferedPosition = 0L
    // Dart already seeds the visible timeline before open. Emitting native
    // zeroes here races HLS resume/restart state back to 0:00.
    delegate?.onPropertyChange("eof-reached", false)
  }

  private fun emitSeekable(seekable: Boolean, force: Boolean = false) {
    if (!force && lastSeekable == seekable) return
    lastSeekable = seekable
    delegate?.onPropertyChange("seekable", seekable)
  }

  private fun emitCurrentSeekable(force: Boolean = false) {
    val player = exoPlayer
    val hasMedia = player?.currentMediaItem != null
    val seekable = hasMedia &&
      !currentMediaIsLive &&
      (forceSeekable || player?.isCurrentMediaItemSeekable == true)
    emitSeekable(seekable, force)
  }

  fun setForceSeekable(force: Boolean) {
    if (forceSeekable == force) return
    forceSeekable = force
    emitCurrentSeekable(force = true)
  }

  // Player.Listener

  override fun onCues(cueGroup: CueGroup) {
    // ASS subtitles are rendered by the libass overlay surface. This callback
    // handles non-ASS text cues plus bitmap image cues such as PGS/VOB.
    val incoming = cueGroup.cues
    lastSubtitleCues = incoming
    if (incoming.isNotEmpty()) {
      val textCount = incoming.count { it.bitmap == null }
      val bitmapCount = incoming.size - textCount
      Log.d(
        TAG,
        "onCues: received ${incoming.size} cues (non-ASS, text=$textCount, bitmap=$bitmapCount)"
      )
    }
    renderSubtitleCues(incoming)
  }

  private fun renderSubtitleCues(cues: List<Cue>) {
    val textCues = cues.filter { it.bitmap == null }
    val bitmapCues = cues.filter { it.bitmap != null }
    val outgoing = SubtitleCueLayout.layout(textCues, subtitlePositionPercent, subtitleFontSize)
    subtitleView?.setCues(outgoing)
    bitmapSubtitleView?.setCues(bitmapCues)
  }

  private fun handleIsPlayingChanged(isPlaying: Boolean) {
    Log.d(TAG, "onIsPlayingChanged: $isPlaying")
    // A pause/resume can occur entirely between watchdog polls while the
    // looper is stalled. Reset here so paused wall time can never make the
    // first post-resume poll look like an eight-second render stall.
    frameWatchdogStartTime = System.currentTimeMillis()
    if (isPlaying) pendingPlayWhenReady = null
    if (isPlaying) armResumeStallWatchdog() else cancelResumeStallWatchdog()
    delegate?.onPropertyChange("pause", !isPlaying)
  }

  private fun handlePlaybackStateChanged(state: Int) {
    val stateStr = when (state) {
      Player.STATE_IDLE -> "idle"
      Player.STATE_BUFFERING -> "buffering"
      Player.STATE_READY -> "ready"
      Player.STATE_ENDED -> "ended"
      else -> "unknown"
    }
    emitLog("debug", "state", stateStr)
    emitCurrentSeekable()

    when (state) {
      Player.STATE_BUFFERING -> {
        delegate?.onPropertyChange("paused-for-cache", true)
      }
      Player.STATE_READY -> {
        // Restore start position if it was lost during track reselection
        // (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
        if (pendingStartPositionMs > 0L) {
          val currentPos = exoPlayer?.currentPosition ?: 0L
          if (currentPos < 1000L) {
            emitLog("warn", "state", "Position lost (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring")
            exoPlayer?.seekTo(pendingStartPositionMs)
          }
          pendingStartPositionMs = 0L
        }
        val pendingPlay = pendingPlayWhenReady
        val currentPlayWhenReady = exoPlayer?.playWhenReady
        if (pendingPlay != null && currentPlayWhenReady != pendingPlay) {
          emitLog("warn", "state", "playWhenReady lost (now $currentPlayWhenReady, expected $pendingPlay) — restoring")
          exoPlayer?.playWhenReady = pendingPlay
        }
        delegate?.onPropertyChange("paused-for-cache", false)
        emitTrackList()

        // Video becomes ready only when a frame renders. Audio-only media has
        // no video callback, so STATE_READY is its renderer-ready boundary.
        val hasVideoGroup = exoPlayer?.currentTracks?.groups?.any { it.type == C.TRACK_TYPE_VIDEO } == true
        if (hasVideoGroup) {
          startFrameWatchdog()
        } else if (claimPlaybackOutputReady()) {
          emitLog("debug", "audio", "Audio-only playback ready")
          stopFrameWatchdog()
          delegate?.onEvent("playback-restart", null)
        }
      }
      Player.STATE_ENDED -> {
        stopFrameWatchdog()
        delegate?.onPropertyChange("eof-reached", true)
        delegate?.onEvent("end-file", mapOf("reason" to "eof"))
      }
    }
  }

  override fun onTracksChanged(tracks: Tracks) {
    Log.d(TAG, "onTracksChanged")
    // Player.Listener does not identify the originating MediaItem. A queued
    // callback from the replaced item must not publish tracks for the new one;
    // generation-bound unsupported-format handling lives in AnalyticsListener.
    if (tracks != exoPlayer?.currentTracks) {
      Log.d(TAG, "Ignoring stale track callback")
      return
    }

    // Audio renderer bounce (loudness normalization): the playback thread has
    // observed the disabled audio renderer — re-enable so selection re-queries
    // the sink's direct-output verdict.
    if (pendingAudioRendererBounce && tracks.groups.none { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) {
      completeAudioRendererBounce("audio-normalization (audio renderer back on)")
      return // skip processing the intermediate no-audio track list
    }

    if (restorePendingTrackSelection(tracks)) return

    // Log selected video and audio track details
    val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected }
    val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }
    if (videoGroup != null) {
      val vf = videoGroup.mediaTrackGroup.getFormat(0)
      currentVideoFormat = vf
      val hdr = vf.colorInfo?.let { ci ->
        val transfer = ci.colorTransfer
        if (transfer != null && transfer != 0) " HDR(transfer=$transfer)" else ""
      } ?: ""
      emitLog("info", "tracks", "Video: ${vf.codecs} ${vf.width}x${vf.height}$hdr")
      logNativeDvSelectionIfNeeded(vf)
      logDolbyVisionPlaybackPathIfNeeded()
    } else {
      currentVideoFormat = null
    }
    if (audioGroup != null) {
      val af = audioGroup.mediaTrackGroup.getFormat(0)
      emitLog("info", "tracks", "Audio: ${formatAudioSummary(af)}")
      if (af.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
        updateAudioDecoderPolicy("tracks changed", af)
      }
    }

    // Disabling the text track produces no trailing empty CueGroup, and no new
    // video frame re-renders the libass overlay while paused, so the last SRT/VTT
    // cue stays painted on the SubtitleViews and the last ASS frame stays on the
    // overlay. AssHandler (registered before this listener) has already nulled the
    // libass track by now, so re-rendering the last position clears it. Gate on the
    // transition to avoid redundant clears on every track change. (#1387)
    val hasSelectedText = hasSelectedTextTrack(tracks)
    if (!hasSelectedText && hadSelectedTextTrack) {
      lastSubtitleCues = emptyList()
      subtitleView?.setCues(emptyList())
      bitmapSubtitleView?.setCues(emptyList())
      assSubtitleView?.invalidateSubtitles()
    }
    hadSelectedTextTrack = hasSelectedText

    evaluateAudioCodecForTunneling()
    evaluateVideoCodecForTunneling()
    evaluateAssSubtitlesForTunneling(tracks)
    updateTunnelingState("tracks changed")
    emitTrackList()
  }

  private fun handlePlayerError(error: PlaybackException, mediaGeneration: Int) {
    // Log full exception chain unminified — R8 mangles simpleName but not toString/message
    val causeChain = buildString {
      var t: Throwable? = error.cause
      while (t != null) {
        if (isNotEmpty()) append(" → ")
        append("${t.javaClass.name}: ${t.message}")
        t = t.cause
      }
    }
    emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${causeChain.ifEmpty { "none" }}")
    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    emitSeekable(false, force = true)

    // If native DV7 failed, retry with conversion before falling to MPV
    if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return

    // Server returned HTTP 500 — typically a shared-user bandwidth/transcoding limit
    // set by the server owner. MPV will hit the same rejection, so skip the fallback.
    // Keep the "server-http-500" tag in sync with PlayerError.serverHttp500 in Dart.
    val isHttp500 =
      causeChain.contains("Response code: 500") ||
        (error.message?.contains("Response code: 500") == true)
    if (isHttp500) {
      Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)")
      emitPlaybackErrorOnce(
        mediaGeneration,
        error.message ?: "HTTP 500",
        cause = "server-http-500"
      )
      return
    }

    if (retryAfterAudioTrackError(error, causeChain)) return

    val rendererFormat = (error as? ExoPlaybackException)?.rendererFormat
    val isVideoRenderer = rendererFormat?.sampleMimeType?.startsWith("video/") == true
    if (
      VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(error.errorCode, isVideoRenderer) &&
      retryVideoDecoderInPlace(PlaybackException.getErrorCodeName(error.errorCode))
    ) {
      return
    }

    val uri = currentMediaUri
    if (uri != null) {
      Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV")
      requestFormatFallback(
        mediaGeneration = mediaGeneration,
        uri = uri,
        positionMs = effectivePosition,
        playWhenReady = exoPlayer?.playWhenReady ?: true,
        errorMessage = error.message ?: "Unknown error"
      )
      return
    }

    emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error")
  }

  private fun retryVideoDecoderInPlace(reason: String): Boolean {
    if (!VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
        hasRenderedVideoFrameForMedia,
        videoDecoderRecoveryConsecutiveAttempts,
        videoDecoderRecoveryTotalAttempts
      )
    ) {
      return false
    }

    val player = exoPlayer ?: return false
    val uri = currentMediaUri ?: return false
    val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
    val savedPlayWhenReady = player.playWhenReady
    val previousDecoder = decoderInitName
    pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
      emitLog(
        "info",
        "track-restore",
        "Saved selection before decoder recovery: audio=${restore.audio?.describe() ?: "none"}, " +
          "subtitle=${restore.subtitle?.describe() ?: if (restore.subtitleDisabled) "off" else "none"}"
      )
    }

    videoDecoderRecoveryConsecutiveAttempts++
    videoDecoderRecoveryTotalAttempts++
    videoDecoderRecoveryPositionMs = savedPosition
    pendingStartPositionMs = savedPosition
    pendingPlayWhenReady = savedPlayWhenReady
    decoderInitName = null
    firstFrameRendered = false
    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    applyTrackSelectorPolicy(
      reason = "video decoder recovery",
      forceSelector = true,
      clearAudioOverrides = true,
      clearTextOverrides = true
    )

    emitLog(
      "warn",
      "video-recovery",
      "Retrying warm decoder in ExoPlayer " +
        "(consecutive=$videoDecoderRecoveryConsecutiveAttempts/${VideoDecoderRecoveryPolicy.MAX_CONSECUTIVE_ATTEMPTS}, " +
        "total=$videoDecoderRecoveryTotalAttempts/${VideoDecoderRecoveryPolicy.MAX_ATTEMPTS_PER_MEDIA}) at ${savedPosition}ms; " +
        "reason=$reason, decoder=${previousDecoder ?: "unknown"}"
    )

    setCurrentMediaSource(player, uri, savedPosition)
    player.prepare()
    player.playWhenReady = savedPlayWhenReady
    return true
  }

  private fun updateVideoDecoderRecoveryHealth(currentPositionMs: Long, isPlaying: Boolean) {
    val recoveryPositionMs = videoDecoderRecoveryPositionMs ?: return
    if (!VideoDecoderRecoveryPolicy.hasSustainedPlayback(
        hasRenderedFrame = firstFrameRendered,
        isPlaying = isPlaying,
        recoveryPositionMs = recoveryPositionMs,
        currentPositionMs = currentPositionMs
      )
    ) {
      return
    }

    videoDecoderRecoveryConsecutiveAttempts = 0
    videoDecoderRecoveryPositionMs = null
    emitLog(
      "info",
      "video-recovery",
      "Decoder recovery confirmed after ${currentPositionMs - recoveryPositionMs}ms of playback"
    )
  }

  private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
    if (!isAudioTrackError(error.errorCode)) return false

    val player = exoPlayer ?: return false
    val uri = currentMediaUri ?: return false
    if (audioRecoveryAttempts >= MAX_AUDIO_RECOVERY_ATTEMPTS) {
      emitLog(
        "warn",
        "audio-recovery",
        "ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for ${PlaybackException.getErrorCodeName(error.errorCode)}"
      )
      return false
    }

    val selectedFormat = selectedAudioFormat()
    val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
      format.sampleMimeType?.startsWith("audio/") == true
    }
    val recoveryFormat = selectedFormat ?: errorFormat
    val actions = mutableListOf<String>()

    recoveryFormat?.sampleMimeType
      ?.takeIf { isEncodedAudioMimeType(it) }
      ?.let { mimeType ->
        if (directAudioOutputBlockedAfterFailure.add(mimeType)) {
          actions.add("force-decoded-pcm($mimeType)")
        }
      }

    if (!tunnelingDisabledForAudioRecovery) {
      tunnelingDisabledForAudioRecovery = true
      actions.add("disable-tunneling")
    }
    if (actions.isEmpty()) actions.add("reload")

    audioRecoveryAttempts++
    val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
    val savedPlayWhenReady = player.playWhenReady
    val previousAudioTrackConfig = lastAudioTrackConfig
    pendingStartPositionMs = savedPosition
    pendingPlayWhenReady = savedPlayWhenReady
    audioDecoderInitName = null
    lastAudioTrackConfig = null
    lastAudioRecoveryAction = actions.joinToString(",")
    lastAudioRecoveryReason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}"

    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)

    emitLog(
      "warn",
      "audio-recovery",
      "Retrying in ExoPlayer ($audioRecoveryAttempts/$MAX_AUDIO_RECOVERY_ATTEMPTS) at ${savedPosition}ms; " +
        "format=${recoveryFormat?.let { formatAudioSummary(it) } ?: "unknown"}, " +
        "lastOutput=${describeAudioTrackConfig(previousAudioTrackConfig)}, actions=$lastAudioRecoveryAction"
    )

    setCurrentMediaSource(player, uri, savedPosition)
    player.prepare()
    player.playWhenReady = savedPlayWhenReady
    return true
  }

  private fun isAudioTrackError(errorCode: Int): Boolean = when (errorCode) {
    PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
    PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
    PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED,
    PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED -> true
    else -> false
  }

  private fun isEncodedAudioMimeType(mimeType: String): Boolean = mimeType.startsWith("audio/") && mimeType != MimeTypes.AUDIO_RAW

  private fun activeDoviTrackOutput(): DoviConvertingTrackOutput? = activeDoviMkvWrapper?.doviTrackOutput ?: activeDoviMp4Wrapper?.doviTrackOutput

  private fun dolbyVisionProfile(format: Format?): Int? {
    val codecs = format?.codecs?.lowercase() ?: return null
    return DV_CODEC_PROFILE_REGEX.find(codecs)?.groupValues?.getOrNull(1)?.toIntOrNull()
  }

  private fun isDvProfile7Format(format: Format): Boolean = dolbyVisionProfile(format) == 7

  private fun buildDvPlaybackInfo(format: Format?, decoderName: String?): DvPlaybackInfo? {
    val doviTrack = activeDoviTrackOutput()
    val conversionActive = doviTrack?.conversionActive == true
    val sourceProfile = if (conversionActive) 7 else (dolbyVisionProfile(format) ?: return null)
    val decoder = decoderName ?: decoderInitName ?: getVideoDecoderInfo(format) ?: "unknown"
    val mimeType = format?.sampleMimeType

    val (path, reason) = when {
      sourceProfile == 7 && dvMode == DvConversionMode.DV81 ->
        "P7 -> P8.1" to "Profile 7 conversion is active; RPU metadata is converted to Profile 8.1"
      sourceProfile == 7 && dvMode == DvConversionMode.HEVC_STRIP ->
        "P7 -> HEVC" to "Profile 7 HEVC strip is active; DV RPU/EL metadata is removed"
      sourceProfile == 7 ->
        "Native DV P7" to "Profile 7 conversion is disabled; trying native Dolby Vision decode"
      sourceProfile == 8 && mimeType != MimeTypes.VIDEO_DOLBY_VISION ->
        "HDR fallback" to "Profile 8 is being decoded through the HEVC/HDR10-compatible path"
      sourceProfile == 8 ->
        "DV P8 passthrough" to "Profile 8 is being passed through the native Dolby Vision-capable path"
      else ->
        "Native DV P$sourceProfile" to "Dolby Vision profile $sourceProfile is being sent to the native decoder path"
    }

    return DvPlaybackInfo(
      sourceProfile = sourceProfile,
      path = path,
      reason = reason,
      decoder = decoder
    )
  }

  private fun logDolbyVisionPlaybackPathIfNeeded(decoderName: String? = decoderInitName) {
    val format = currentVideoFormat ?: exoPlayer?.videoFormat ?: return
    val info = buildDvPlaybackInfo(format, decoderName) ?: return
    lastDvPlaybackInfo = info
    val key = "${info.sourceProfile}|${format.sampleMimeType}|${format.codecs}|${info.decoder}|${info.path}|$dvMode"
    if (loggedDvPlaybackPathKey == key) return
    loggedDvPlaybackPathKey = key
    emitLog(
      "info",
      "dv-playback",
      "DV source: profile=${info.sourceProfile}, path=${info.path}, reason=${info.reason}, " +
        "mime=${format.sampleMimeType}, codecs=${format.codecs}, decoder=${info.decoder}, p7Mode=$dvMode, " +
        "displayDV=${DoviBridge.displaySupportsDolbyVision(activity)}, " +
        "advertisedP7=${DoviBridge.deviceSupportsDvProfile7}, advertisedP8=${DoviBridge.deviceSupportsDvProfile8}"
    )
  }

  private fun findDv7VideoFormat(): Format? {
    currentVideoFormat?.takeIf { isDvProfile7Format(it) }?.let { return it }
    val tracks = exoPlayer?.currentTracks ?: return null
    for (group in tracks.groups) {
      if (group.type != C.TRACK_TYPE_VIDEO) continue
      for (i in 0 until group.mediaTrackGroup.length) {
        val format = group.mediaTrackGroup.getFormat(i)
        if (isDvProfile7Format(format)) return format
      }
    }
    return null
  }

  private fun describeVideoFormat(format: Format?): String {
    if (format == null) return "none"
    return "mime=${format.sampleMimeType}, codecs=${format.codecs}, size=${format.width}x${format.height}"
  }

  private fun logNativeDvSelectionIfNeeded(format: Format) {
    if (dvMode != DvConversionMode.DISABLED || !isDvProfile7Format(format)) return
    val key = "${format.sampleMimeType}|${format.codecs}|${format.width}x${format.height}"
    if (loggedNativeDvSelectionKey == key) return
    loggedNativeDvSelectionKey = key
    emitLog(
      "info",
      "dv-native",
      "Selected DV Profile 7 for native playback: ${describeVideoFormat(format)}, " +
        "displayDV=${DoviBridge.displaySupportsDolbyVision(activity)}, " +
        "nativeDecoder=${DoviBridge.hasNativeDolbyVisionDecoder}, " +
        "advertisedP7=${DoviBridge.deviceSupportsDvProfile7}, advertisedP8=${DoviBridge.deviceSupportsDvProfile8}"
    )
  }

  private fun logNativeDvFirstFrameIfNeeded() {
    if (loggedNativeDvFirstFrame || dvMode != DvConversionMode.DISABLED) return
    val format = currentVideoFormat ?: return
    if (!isDvProfile7Format(format)) return
    loggedNativeDvFirstFrame = true
    emitLog(
      "info",
      "dv-native",
      "Native DV Profile 7 playback confirmed: ${describeVideoFormat(format)}, decoder=${decoderInitName ?: "unknown"}"
    )
  }

  /**
   * When native DV7 decoding fails, upgrade to DV7→8.1 conversion or HEVC strip
   * and reload the media. Returns true if retry was initiated.
   */
  private fun retryWithDvConversion(reason: String): Boolean {
    if (dv7RetryAttempted) return false
    if (debugDvModeOverride == DvConversionMode.DISABLED) {
      emitLog("debug", "dv-fallback", "Skipping DV conversion retry for $reason: native/disabled mode is forced")
      return false
    }
    if (dvMode != DvConversionMode.DISABLED) {
      emitLog("debug", "dv-fallback", "Skipping DV conversion retry for $reason: conversion already active ($dvMode)")
      return false
    }
    if (currentMediaUri == null) return false
    val dv7Format = findDv7VideoFormat()
    if (dv7Format == null) {
      emitLog(
        "debug",
        "dv-fallback",
        "Skipping DV conversion retry for $reason: current video is not DV Profile 7 (${describeVideoFormat(currentVideoFormat)})"
      )
      return false
    }

    dv7RetryAttempted = true
    val fallbackDecision = DoviBridge.getDv7FallbackDecision(activity)
    val newMode = fallbackDecision.mode
    dvMode = newMode
    Log.i(TAG, "Native DV7 playback failed ($reason, ${describeVideoFormat(dv7Format)}), retrying with $newMode")
    emitLog("info", "dv-fallback", "${fallbackDecision.logMessage()}; trigger=$reason")
    emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode")

    return reloadCurrentMediaForDvMode()
  }

  override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
    val mediaGeneration = mediaItem?.mediaId?.toIntOrNull() ?: return
    if (mediaGeneration != currentMediaGeneration) return
    Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason")
    delegate?.onEvent("file-loaded", null)
    delegate?.onPropertyChange("eof-reached", false)
    emitCurrentSeekable(force = true)
    // Re-calibrate the subtitle/video layer offset each play: tear down the converged calibrator
    // so the metadata listener lazily spins up a fresh one. The seeded (persisted) value stays
    // applied meanwhile, so subtitles are right from the first frame.
    latencyCalibrator?.stop()
    latencyCalibrator = null
    assSubtitleView?.setPreSwapProbe(null)
  }

  override fun onVideoSizeChanged(videoSize: VideoSize) {
    Log.d(TAG, "Video size changed: ${videoSize.width}x${videoSize.height}, ratio: ${videoSize.pixelWidthHeightRatio}")
    lastVideoSize = videoSize
    updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio)
  }

  private fun updateSurfaceViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) {
    if (disposing) return
    if (videoWidth == 0 || videoHeight == 0) return

    val videoAspect = (videoWidth * pixelRatio) / videoHeight
    activity.runOnUiThread {
      videoAspectContainer?.setAspectRatio(videoAspect)
    }
    updateSubtitleViewSize(videoWidth, videoHeight, pixelRatio)
    updateAssMargins()
  }

  private fun updateSubtitleViewSize(videoWidth: Int, videoHeight: Int, pixelRatio: Float) {
    if (disposing) return
    if (videoWidth == 0 || videoHeight == 0) return

    val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
    val containerWidth = surfaceContainer?.width?.takeIf { it > 0 } ?: contentView.width
    val containerHeight = surfaceContainer?.height?.takeIf { it > 0 } ?: contentView.height
    if (containerWidth == 0 || containerHeight == 0) return

    val resizeMode = videoAspectContainer?.resizeMode ?: AspectRatioFrameLayout.RESIZE_MODE_FIT
    val textDimensions = SubtitleViewLayout.textDimensions(
      containerWidth,
      containerHeight,
      videoWidth,
      videoHeight,
      pixelRatio,
      resizeMode,
      videoZoomScale
    )
    val bitmapDimensions = SubtitleViewLayout.bitmapDimensions(
      containerWidth,
      containerHeight,
      videoWidth,
      videoHeight,
      pixelRatio,
      resizeMode,
      videoZoomScale
    )

    activity.runOnUiThread {
      val textView = subtitleView
      if (textView != null && textDimensions != null) {
        applySubtitleViewSize(textView, textDimensions)
      }
      val bitmapView = bitmapSubtitleView
      if (bitmapView != null && bitmapDimensions != null) {
        applySubtitleViewSize(bitmapView, bitmapDimensions)
      }
    }
  }

  private fun applySubtitleViewSize(view: View, dimensions: SubtitleViewDimensions) {
    // Skip when already at the target size: setLayoutParams always schedules a
    // layout pass, and this runs from the global-layout listener — re-applying
    // equal params would keep the UI thread laying out every frame (#1261).
    val current = view.layoutParams as? FrameLayout.LayoutParams
    if (current != null &&
      current.width == dimensions.width &&
      current.height == dimensions.height &&
      current.gravity == Gravity.CENTER
    ) {
      return
    }
    view.layoutParams = FrameLayout.LayoutParams(dimensions.width, dimensions.height).apply {
      gravity = Gravity.CENTER
    }
  }

  // Pushes mpv-style libass margins: the offsets of the video dst rect within the
  // full-screen container (= the libass frame). Negative when the video extends past
  // the screen (cover mode, zoom > 1) — libass supports that explicitly. Pure math +
  // a native setter (no view mutation), so it is safe to run per layout pass and per
  // pinch-zoom tick (#1261 no-churn invariant).
  private fun updateAssMargins() {
    if (disposing) return
    val handler = assHandler ?: return
    val vs = lastVideoSize ?: return
    if (vs.width == 0 || vs.height == 0) return

    activity.runOnUiThread {
      if (disposing) return@runOnUiThread
      val containerWidth = surfaceContainer?.width ?: 0
      val containerHeight = surfaceContainer?.height ?: 0
      if (containerWidth == 0 || containerHeight == 0) return@runOnUiThread

      val videoAspect = (vs.width * vs.pixelWidthHeightRatio) / vs.height
      val containerAspect = containerWidth.toFloat() / containerHeight
      val resizeMode = videoAspectContainer?.resizeMode ?: AspectRatioFrameLayout.RESIZE_MODE_FIT

      // Mirror AspectRatioFrameLayout.onMeasure: aspect mismatches <= 1% are
      // absorbed by stretching to the container instead of resizing.
      val (baseWidth, baseHeight) = if (kotlin.math.abs(videoAspect / containerAspect - 1f) <= 0.01f) {
        containerWidth.toFloat() to containerHeight.toFloat()
      } else {
        when (resizeMode) {
          // Cover: scale up to fill the container, cropping the overflow
          AspectRatioFrameLayout.RESIZE_MODE_ZOOM ->
            if (videoAspect > containerAspect) {
              containerHeight * videoAspect to containerHeight.toFloat()
            } else {
              containerWidth.toFloat() to containerWidth / videoAspect
            }
          // Stretch: video fills the container, aspect overridden
          AspectRatioFrameLayout.RESIZE_MODE_FILL ->
            containerWidth.toFloat() to containerHeight.toFloat()
          // Fit: letterbox within the container
          else -> SubtitleViewLayout.letterbox(containerWidth, containerHeight, videoAspect)
        }
      }

      // videoAspectContainer is centered and zoom-scaled about its center.
      val videoWidth = Math.round(baseWidth * videoZoomScale)
      val videoHeight = Math.round(baseHeight * videoZoomScale)
      val left = (containerWidth - videoWidth) / 2
      val top = (containerHeight - videoHeight) / 2
      val right = containerWidth - videoWidth - left
      val bottom = containerHeight - videoHeight - top

      val margins = intArrayOf(top, bottom, left, right)
      if (lastAssMargins?.contentEquals(margins) == true) return@runOnUiThread
      lastAssMargins = margins
      handler.setMargins(top, bottom, left, right)
      // Repaint at the current position so changes are visible while paused; during
      // playback the next video frame's render supersedes it (latest-wins).
      assSubtitleView?.invalidateSubtitles()
    }
  }

  // mpv's sub-ass-force-margins, live-applied from the Dart-managed property: lay out
  // non-positioned ASS events against the visible screen instead of the video rect.
  fun setAssForceMargins(force: Boolean) {
    if (disposing) return
    activity.runOnUiThread {
      if (disposing || assForceMargins == force) return@runOnUiThread
      assForceMargins = force
      assHandler?.setUseMargins(force)
      assSubtitleView?.invalidateSubtitles()
    }
  }

  private fun boxFitModeToResizeMode(mode: Int): Int = when (mode) {
    1 -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
    2 -> AspectRatioFrameLayout.RESIZE_MODE_FILL
    else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
  }

  fun setBoxFitMode(mode: Int) {
    if (disposing) return
    val resizeMode = boxFitModeToResizeMode(mode.coerceIn(0, 2))
    activity.runOnUiThread {
      val container = videoAspectContainer ?: return@runOnUiThread
      if (container.resizeMode == resizeMode) return@runOnUiThread
      container.resizeMode = resizeMode
      lastVideoSize?.let { vs ->
        if (vs.width > 0 && vs.height > 0) {
          updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
        }
      }
      updateAssMargins()
    }
  }

  fun setVideoZoom(scale: Double) {
    if (disposing) return
    val clamped = scale.coerceIn(0.5, 2.0).toFloat()
    activity.runOnUiThread {
      if (clamped == videoZoomScale) return@runOnUiThread
      videoZoomScale = clamped
      videoAspectContainer?.scaleX = clamped
      videoAspectContainer?.scaleY = clamped
      lastVideoSize?.let { vs ->
        if (vs.width > 0 && vs.height > 0) {
          updateSubtitleViewSize(vs.width, vs.height, vs.pixelWidthHeightRatio)
        }
      }
      updateAssMargins()
    }
  }

  private fun emitTrackList() {
    val player = exoPlayer ?: return
    val tracks = player.currentTracks

    val trackList = mutableListOf<Map<String, Any?>>()
    audioTrackGroupMap.clear()
    subtitleTrackGroupMap.clear()

    // Group tracks by type and use group index as track ID (matching select functions)
    val audioGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
    val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
    val videoGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }

    var selectedAudioId: String? = null
    var selectedSubId: String? = null

    // Process audio tracks
    audioGroups.forEachIndexed { groupIndex, group ->
      val trackGroup = group.mediaTrackGroup
      // Use first format in group as the representative track
      val format = trackGroup.getFormat(0)
      val trackId = "${C.TRACK_TYPE_AUDIO}_$groupIndex"
      audioTrackGroupMap[trackId] = trackGroup
      val isSelected = group.isSelected

      val track = mutableMapOf<String, Any?>(
        "type" to "audio",
        "id" to trackId,
        "title" to format.label,
        "lang" to format.language,
        "codec" to format.codecs,
        "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0),
        "selected" to isSelected,
        "demux-channel-count" to format.channelCount,
        "demux-samplerate" to format.sampleRate
      )
      trackList.add(track)

      if (isSelected) {
        selectedAudioId = trackId
      }
    }

    // Process subtitle tracks (embedded + side-loaded external)
    Log.d(TAG, "emitTrackList: found ${textGroups.size} subtitle track groups")
    textGroups.forEachIndexed { groupIndex, group ->
      val trackGroup = group.mediaTrackGroup
      val format = trackGroup.getFormat(0)
      val trackId = "${C.TRACK_TYPE_TEXT}_$groupIndex"
      subtitleTrackGroupMap[trackId] = trackGroup
      val isSelected = group.isSelected

      // Detect external (side-loaded) subtitle by the ID prefix set in open()
      val isExternal = format.id?.startsWith("external_") == true
      val externalIndex = if (isExternal) format.id?.removePrefix("external_")?.toIntOrNull() else null
      val externalUri = externalIndex?.takeIf { it in externalSubtitleUris.indices }?.let { externalSubtitleUris[it] }
      val isContainer = !isExternal && externalSubtitleContainerUris.isNotEmpty()
      val containerUri = if (isContainer) externalSubtitleContainerUris.first() else null

      Log.d(TAG, "Subtitle track $groupIndex: codec=${format.codecs}, lang=${format.language}, selected=$isSelected, external=$isExternal")

      val track = mutableMapOf<String, Any?>(
        "type" to "sub",
        "id" to trackId,
        "title" to format.label,
        "lang" to format.language,
        "codec" to format.codecs,
        "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0),
        "forced" to (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0),
        "selected" to isSelected,
        "external" to (isExternal || isContainer),
        "container" to isContainer,
        "external-filename" to (externalUri ?: containerUri)
      )
      trackList.add(track)

      if (isSelected) {
        selectedSubId = trackId
      }
    }

    // Process video tracks (for completeness, typically only one)
    videoGroups.forEachIndexed { groupIndex, group ->
      val trackGroup = group.mediaTrackGroup
      val format = trackGroup.getFormat(0)
      val trackId = "${C.TRACK_TYPE_VIDEO}_$groupIndex"

      val track = mutableMapOf<String, Any?>(
        "type" to "video",
        "id" to trackId,
        "title" to format.label,
        "lang" to format.language,
        "codec" to format.codecs,
        "default" to (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0),
        "selected" to group.isSelected
      )
      trackList.add(track)
    }

    // Emit selected track IDs
    if (selectedAudioId != null) {
      selectedAudioTrackId = selectedAudioId
      delegate?.onPropertyChange("aid", selectedAudioId)
    }

    if (selectedSubId != null) {
      selectedSubtitleTrackId = selectedSubId
      delegate?.onPropertyChange("sid", selectedSubId)
    } else if (textGroups.isNotEmpty()) {
      selectedSubtitleTrackId = "no"
      delegate?.onPropertyChange("sid", "no")
    }

    delegate?.onPropertyChange("track-list", trackList)
  }

  private fun captureTrackRestore(): PendingTrackRestore? {
    val tracks = exoPlayer?.currentTracks ?: return null
    val audio = captureSelectedTrackIdentity(tracks, C.TRACK_TYPE_AUDIO)
      ?: captureMappedTrackIdentity(selectedAudioTrackId, audioTrackGroupMap, C.TRACK_TYPE_AUDIO)
    val subtitle = captureSelectedTrackIdentity(tracks, C.TRACK_TYPE_TEXT)
      ?: captureMappedTrackIdentity(selectedSubtitleTrackId, subtitleTrackGroupMap, C.TRACK_TYPE_TEXT)
    val subtitleDisabled = subtitle == null && selectedSubtitleTrackId == "no"

    if (audio == null && subtitle == null && !subtitleDisabled) return null
    return PendingTrackRestore(audio, subtitle, subtitleDisabled)
  }

  private fun captureSelectedTrackIdentity(tracks: Tracks, type: Int): TrackRestoreIdentity? {
    var typeGroupIndex = 0
    for (group in tracks.groups) {
      if (group.type != type) continue
      val trackIndex = selectedTrackIndex(group)
      if (trackIndex != null) {
        return buildTrackRestoreIdentity(type, typeGroupIndex, trackIndex, group.mediaTrackGroup.getFormat(trackIndex))
      }
      typeGroupIndex++
    }
    return null
  }

  private fun captureMappedTrackIdentity(
    trackId: String?,
    trackMap: Map<String, TrackGroup>,
    type: Int
  ): TrackRestoreIdentity? {
    if (trackId == null || trackId == "no") return null
    val trackGroup = trackMap[trackId] ?: return null
    if (trackGroup.length == 0) return null
    val groupIndex = trackId.substringAfter('_', "").toIntOrNull() ?: -1
    return buildTrackRestoreIdentity(type, groupIndex, 0, trackGroup.getFormat(0))
  }

  private fun selectedTrackIndex(group: Tracks.Group): Int? {
    if (!group.isSelected) return null
    for (trackIndex in 0 until group.mediaTrackGroup.length) {
      if (group.isTrackSelected(trackIndex)) return trackIndex
    }
    return if (group.mediaTrackGroup.length > 0) 0 else null
  }

  private fun buildTrackRestoreIdentity(
    type: Int,
    groupIndex: Int,
    trackIndex: Int,
    format: Format
  ): TrackRestoreIdentity = TrackRestoreIdentity(
    type = type,
    groupIndex = groupIndex,
    trackIndex = trackIndex,
    formatId = normalizedText(format.id),
    label = normalizedText(format.label),
    language = normalizedLanguage(format.language),
    sampleMimeType = normalizedText(format.sampleMimeType),
    codecs = normalizedText(format.codecs),
    channelCount = format.channelCount,
    sampleRate = format.sampleRate,
    selectionFlags = format.selectionFlags
  )

  private fun hasSelectedTextTrack(tracks: Tracks): Boolean = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }

  private fun restorePendingTrackSelection(tracks: Tracks): Boolean {
    val pending = pendingTrackRestore ?: return false
    if (trackSelector == null) return false
    if (shouldWaitForRestoreTracks(pending, tracks)) return true
    pendingTrackRestore = null

    val audioMatch = pending.audio?.let { findTrackRestoreMatch(tracks, it) }
    val subtitleMatch = pending.subtitle?.let { findTrackRestoreMatch(tracks, it) }
    val hasSelectedText = hasSelectedTextTrack(tracks)
    var selectionWillChange = false
    var appliedRestore = false
    var audioOverride: TrackSelectionOverride? = null
    var textOverride: TrackSelectionOverride? = null
    var clearTextOverrides = false
    var textDisabled: Boolean? = null

    if (pending.audio != null) {
      if (audioMatch != null) {
        audioOverride = TrackSelectionOverride(audioMatch.trackGroup, audioMatch.trackIndex)
        selectionWillChange = selectionWillChange || !audioMatch.isSelected()
        appliedRestore = true
        emitLog("info", "track-restore", "Restoring audio after media reload: ${pending.audio.describe()} -> ${audioMatch.format.describeTrackFormat()} score=${audioMatch.score}")
      } else {
        emitLog("warn", "track-restore", "Could not restore audio after media reload: ${pending.audio.describe()}")
      }
    }

    if (pending.subtitleDisabled) {
      clearTextOverrides = true
      textDisabled = true
      selectionWillChange = selectionWillChange || hasSelectedText
      appliedRestore = true
      emitLog("info", "track-restore", "Restoring subtitles off after media reload")
    } else if (pending.subtitle != null) {
      if (subtitleMatch != null) {
        textOverride = TrackSelectionOverride(subtitleMatch.trackGroup, subtitleMatch.trackIndex)
        textDisabled = false
        selectionWillChange = selectionWillChange || !subtitleMatch.isSelected()
        appliedRestore = true
        emitLog("info", "track-restore", "Restoring subtitle after media reload: ${pending.subtitle.describe()} -> ${subtitleMatch.format.describeTrackFormat()} score=${subtitleMatch.score}")
      } else {
        emitLog("warn", "track-restore", "Could not restore subtitle after media reload: ${pending.subtitle.describe()}")
      }
    }

    if (appliedRestore) {
      audioMatch?.let { updateAudioCodecForTunneling(it.format) }
      evaluateVideoCodecForTunneling()
      applyTrackSelectorPolicy(
        reason = "track restore",
        forceSelector = true,
        audioOverride = audioOverride,
        audioDisabled = if (audioOverride != null) false else null,
        clearTextOverrides = clearTextOverrides,
        textOverride = textOverride,
        textDisabled = textDisabled
      )
    }
    return selectionWillChange
  }

  private fun shouldWaitForRestoreTracks(pending: PendingTrackRestore, tracks: Tracks): Boolean {
    val hasAudioGroups = tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO }
    val hasTextGroups = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT }
    return (pending.audio != null && !hasAudioGroups) ||
      (pending.subtitle != null && !hasTextGroups)
  }

  private fun findTrackRestoreMatch(tracks: Tracks, identity: TrackRestoreIdentity): TrackRestoreMatch? {
    var bestMatch: TrackRestoreMatch? = null
    var typeGroupIndex = 0
    for (group in tracks.groups) {
      if (group.type != identity.type) continue
      val trackGroup = group.mediaTrackGroup
      for (trackIndex in 0 until trackGroup.length) {
        val format = trackGroup.getFormat(trackIndex)
        val score = scoreTrackRestoreMatch(identity, format, typeGroupIndex, trackIndex)
        if (bestMatch == null || score > bestMatch.score) {
          bestMatch = TrackRestoreMatch(group, trackGroup, trackIndex, format, score)
        }
      }
      typeGroupIndex++
    }

    val minimumScore = if (identity.type == C.TRACK_TYPE_AUDIO) 35 else 30
    return bestMatch?.takeIf { it.score >= minimumScore }
  }

  private fun scoreTrackRestoreMatch(
    identity: TrackRestoreIdentity,
    format: Format,
    groupIndex: Int,
    trackIndex: Int
  ): Int {
    var score = 0
    val candidateId = normalizedText(format.id)
    if (identity.formatId != null && candidateId != null) {
      score += if (identity.formatId == candidateId) 100 else -15
    }
    val candidateLabel = normalizedText(format.label)
    if (identity.label != null && candidateLabel != null && identity.label == candidateLabel) score += 40
    val candidateLanguage = normalizedLanguage(format.language)
    if (identity.language != null && candidateLanguage != null && identity.language == candidateLanguage) score += 30
    val candidateMime = normalizedText(format.sampleMimeType)
    if (identity.sampleMimeType != null && candidateMime != null && identity.sampleMimeType == candidateMime) score += 12
    val candidateCodecs = normalizedText(format.codecs)
    if (identity.codecs != null && candidateCodecs != null && identity.codecs == candidateCodecs) score += 8
    if (identity.channelCount != Format.NO_VALUE && identity.channelCount == format.channelCount) score += 6
    if (identity.sampleRate != Format.NO_VALUE && identity.sampleRate == format.sampleRate) score += 4
    if (identity.selectionFlags == format.selectionFlags) score += 3
    if (identity.groupIndex == groupIndex) score += 20
    if (identity.trackIndex == trackIndex) score += 5
    return score
  }

  private fun TrackRestoreMatch.isSelected(): Boolean = group.isSelected && group.isTrackSelected(trackIndex)

  private fun TrackRestoreIdentity.describe(): String {
    val parts = mutableListOf<String>()
    language?.let { parts.add("lang=$it") }
    label?.let { parts.add("label=$it") }
    sampleMimeType?.let { parts.add("mime=$it") }
    codecs?.let { parts.add("codecs=$it") }
    if (channelCount != Format.NO_VALUE) parts.add("channels=$channelCount")
    if (sampleRate != Format.NO_VALUE) parts.add("rate=$sampleRate")
    formatId?.let { parts.add("id=$it") }
    parts.add("group=$groupIndex")
    return parts.joinToString(",")
  }

  private fun Format.describeTrackFormat(): String {
    val parts = mutableListOf<String>()
    normalizedLanguage(language)?.let { parts.add("lang=$it") }
    normalizedText(label)?.let { parts.add("label=$it") }
    normalizedText(sampleMimeType)?.let { parts.add("mime=$it") }
    normalizedText(codecs)?.let { parts.add("codecs=$it") }
    if (channelCount != Format.NO_VALUE) parts.add("channels=$channelCount")
    if (sampleRate != Format.NO_VALUE) parts.add("rate=$sampleRate")
    normalizedText(id)?.let { parts.add("id=$it") }
    return parts.joinToString(",")
  }

  private fun normalizedText(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() }

  private fun normalizedLanguage(value: String?): String? = normalizedText(value)?.lowercase()?.takeUnless { it == "und" }

  // Tunneling control — disabled when audio playback cannot use the platform tunneled pipeline.

  private data class TrueHdDirectOutputDecision(
    val blockDirectOutput: Boolean,
    val decisionSource: String,
    val platformProbe: String,
    val media3Probe: String,
    val routeSummary: String
  )

  private fun shouldBlockDirectTrueHd(format: Format, reason: String): Boolean {
    val decision = evaluateTrueHdDirectOutput(format)
    logTrueHdDirectOutputDecision(reason, format, decision)
    return decision.blockDirectOutput
  }

  private fun shouldBlockDirectAudioOutput(format: Format, reason: String): Boolean {
    val mimeType = format.sampleMimeType ?: return false
    // Loudness normalization needs decoded PCM for the audiofx chain to act on.
    if (audioNormalizationEnabled && isEncodedAudioMimeType(mimeType)) return true
    // Stereo downmix runs in the sink's PCM pipeline; bitstream output would
    // bypass it, so encoded audio is force-decoded while downmix is on
    // (overrides the passthrough preference — Android TV defaults it on).
    if (audioDownmixEnabled && isEncodedAudioMimeType(mimeType)) return true
    if (shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled)) return true
    if (directAudioOutputBlockedAfterFailure.contains(mimeType)) {
      if (loggedDirectAudioRecoveryBlocks.add("$mimeType|$reason")) {
        emitLog(
          "info",
          "audio-recovery",
          "Direct $mimeType output disabled after AudioTrack failure (reason=$reason); decoded PCM will be preferred"
        )
      }
      return true
    }
    return mimeType == MimeTypes.AUDIO_TRUEHD && shouldBlockDirectTrueHd(format, reason)
  }

  private fun updateAudioDecoderPolicy(reason: String, format: Format? = null) {
    if (format != null && format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return
    val decision = evaluateTrueHdDirectOutput(format)
    logTrueHdDirectOutputDecision(reason, format, decision)
  }

  private fun shouldForceAppAudioDecoder(mimeType: String): Boolean {
    if (mimeType == MimeTypes.AUDIO_FLAC) return true
    if (DeviceQuirks.isEWaste && isEac3MimeType(mimeType)) {
      if (!loggedEwasteEac3Workaround) {
        loggedEwasteEac3Workaround = true
        emitLog("info", "decoder", "Using app decoder for E-AC3 on this device")
      }
      return true
    }
    return false
  }

  private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
    val probeFormat = trueHdProbeFormat(format)
    val audioAttributes = buildMovieAudioAttributes()
    val platformAttributes = audioAttributes.getPlatformAudioAttributes()

    var platformSupported: Boolean? = null
    val platformProbe = try {
      when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
          val platformFormat = buildTrueHdAudioFormat(probeFormat)
          val support = AudioManager.getDirectPlaybackSupport(platformFormat, platformAttributes)
          val bitstream = (support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
          platformSupported = bitstream
          "api=${Build.VERSION.SDK_INT}, direct=${describeDirectPlaybackSupport(support)}, bitstream=$bitstream"
        }
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
          val platformFormat = buildTrueHdAudioFormat(probeFormat)
          val direct = isDirectPlaybackSupportedApi29(platformFormat, platformAttributes)
          platformSupported = direct
          "api=${Build.VERSION.SDK_INT}, direct=$direct"
        }
        else -> "api=${Build.VERSION.SDK_INT}, direct=unknown"
      }
    } catch (e: Exception) {
      "api=${Build.VERSION.SDK_INT}, direct=error(${e.javaClass.simpleName}: ${e.message})"
    }

    var media3Supported: Boolean? = null
    val media3Probe = try {
      media3Supported = AudioCapabilities
        .getCapabilities(activity, audioAttributes, null)
        .isPassthroughPlaybackSupported(probeFormat, audioAttributes)
      "media3Passthrough=$media3Supported"
    } catch (e: Exception) {
      "media3Passthrough=error(${e.javaClass.simpleName}: ${e.message})"
    }

    // Match the Media3 sink selection path first; Android's direct bitstream flag can
    // disagree on TV routes and incorrectly force TrueHD/Atmos to decoded PCM.
    val (blockDirectOutput, decisionSource) = when {
      media3Supported == true -> false to "media3-passthrough"
      media3Supported == false -> true to "media3-passthrough"
      platformSupported == true -> false to "platform-direct"
      platformSupported == false -> true to "platform-direct"
      else -> false to "unknown-allow"
    }

    return TrueHdDirectOutputDecision(
      blockDirectOutput = blockDirectOutput,
      decisionSource = decisionSource,
      platformProbe = platformProbe,
      media3Probe = media3Probe,
      routeSummary = summarizeAudioOutputRoutes(audioAttributes)
    )
  }

  private fun logTrueHdDirectOutputDecision(reason: String, format: Format?, decision: TrueHdDirectOutputDecision) {
    val key = listOf(
      decision.blockDirectOutput,
      decision.decisionSource,
      decision.platformProbe,
      decision.media3Probe,
      decision.routeSummary,
      format?.id,
      format?.channelCount,
      format?.sampleRate
    ).joinToString("|")
    if (key == lastTrueHdDirectOutputLogKey) return
    lastTrueHdDirectOutputLogKey = key

    emitLog(
      "info",
      "audio",
      "TrueHD direct output ${if (decision.blockDirectOutput) "disabled (decoded PCM fallback)" else "allowed"} " +
        "(reason=$reason, decision=${decision.decisionSource}, format=${formatAudioSummary(trueHdProbeFormat(format))}, " +
        "${decision.platformProbe}, ${decision.media3Probe}, route=${decision.routeSummary})"
    )
  }

  private fun trueHdProbeFormat(format: Format?): Format = if (format?.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
    format
  } else {
    Format.Builder()
      .setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
      .setChannelCount(8)
      .setSampleRate(48_000)
      .build()
  }

  private fun buildMovieAudioAttributes(): androidx.media3.common.AudioAttributes = androidx.media3.common.AudioAttributes.Builder()
    .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
    .setUsage(C.USAGE_MEDIA)
    .build()

  private fun buildTrueHdAudioFormat(format: Format): AudioFormat {
    val channelCount = if (format.channelCount > 0) format.channelCount else 8
    val channelMask = Util.getAudioTrackChannelConfig(channelCount).takeIf { it != 0 }
      ?: AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
    val sampleRate = if (format.sampleRate > 0) format.sampleRate else 48_000
    return AudioFormat.Builder()
      .setEncoding(AudioFormat.ENCODING_DOLBY_TRUEHD)
      .setChannelMask(channelMask)
      .setSampleRate(sampleRate)
      .build()
  }

  @RequiresApi(Build.VERSION_CODES.Q)
  @Suppress("DEPRECATION")
  private fun isDirectPlaybackSupportedApi29(
    audioFormat: AudioFormat,
    audioAttributes: android.media.AudioAttributes
  ): Boolean = AudioTrack.isDirectPlaybackSupported(audioFormat, audioAttributes)

  private fun describeDirectPlaybackSupport(support: Int): String {
    if (support == AudioManager.DIRECT_PLAYBACK_NOT_SUPPORTED) return "none"
    val parts = mutableListOf<String>()
    if ((support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0) parts.add("bitstream")
    if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED) != 0) parts.add("offload")
    if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_GAPLESS_SUPPORTED) != 0) parts.add("offload-gapless")
    if (parts.isEmpty()) parts.add("0x${support.toString(16)}")
    return parts.joinToString("+")
  }

  private fun summarizeAudioOutputRoutes(audioAttributes: androidx.media3.common.AudioAttributes): String = try {
    val audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
    val routedDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
      audioManager.getAudioDevicesForAttributes(audioAttributes.getPlatformAudioAttributes())
    } else {
      emptyList()
    }
    val devices = if (routedDevices.isNotEmpty()) {
      routedDevices
    } else {
      audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
    }
    val source = if (routedDevices.isNotEmpty()) "routed" else "outputs"
    if (devices.isEmpty()) {
      "$source=none"
    } else {
      val extra = if (devices.size > 3) ";+${devices.size - 3}" else ""
      "$source=${devices.take(3).joinToString(";") { describeAudioDevice(it) }}$extra"
    }
  } catch (e: Exception) {
    "error(${e.javaClass.simpleName}: ${e.message})"
  }

  private fun describeAudioDevice(device: AudioDeviceInfo): String {
    val encodings = device.encodings.takeIf { it.isNotEmpty() }
      ?.joinToString("|") { audioEncodingName(it) }
      ?: "any/unknown"
    val channels = device.channelCounts.takeIf { it.isNotEmpty() }
      ?.joinToString("|")
      ?: "unknown"
    val name = device.productName?.toString()?.take(32) ?: "unknown"
    return "${audioDeviceTypeName(device.type)}($name,enc=$encodings,ch=$channels)"
  }

  private fun audioEncodingName(encoding: Int): String = when (encoding) {
    AudioFormat.ENCODING_PCM_16BIT -> "pcm16"
    AudioFormat.ENCODING_PCM_FLOAT -> "pcm-float"
    AudioFormat.ENCODING_AC3 -> "ac3"
    AudioFormat.ENCODING_E_AC3 -> "eac3"
    AudioFormat.ENCODING_E_AC3_JOC -> "eac3-joc"
    AudioFormat.ENCODING_DTS -> "dts"
    AudioFormat.ENCODING_DTS_HD -> "dts-hd"
    AudioFormat.ENCODING_DOLBY_TRUEHD -> "truehd"
    else -> encoding.toString()
  }

  private fun audioDeviceTypeName(type: Int): String = when (type) {
    AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
    AudioDeviceInfo.TYPE_HDMI -> "hdmi"
    AudioDeviceInfo.TYPE_HDMI_ARC -> "hdmi-arc"
    AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi-earc"
    AudioDeviceInfo.TYPE_LINE_DIGITAL -> "line-digital"
    AudioDeviceInfo.TYPE_LINE_ANALOG -> "line-analog"
    AudioDeviceInfo.TYPE_USB_DEVICE -> "usb-device"
    AudioDeviceInfo.TYPE_USB_HEADSET -> "usb-headset"
    AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bt-a2dp"
    AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bt-sco"
    AudioDeviceInfo.TYPE_UNKNOWN -> "unknown"
    else -> "type-$type"
  }

  private fun isEac3MimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_E_AC3 || mimeType == MimeTypes.AUDIO_E_AC3_JOC

  private fun hasHardwareAudioDecoder(mimeType: String): Boolean {
    // Keep tunneling decisions aligned with the MediaCodecSelector exclusions.
    if (shouldForceAppAudioDecoder(mimeType)) return false
    hwAudioDecoderCache[mimeType]?.let { return it }
    val result = try {
      val decoder = MediaCodecQuery.findHardwareDecoder(mimeType)
      if (decoder == null) {
        Log.d(TAG, "No hardware audio decoder for $mimeType — app decoder may handle it")
        false
      } else {
        Log.d(TAG, "Found hardware audio decoder for $mimeType: ${decoder.name}")
        true
      }
    } catch (e: Exception) {
      Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}")
      false
    }
    hwAudioDecoderCache[mimeType] = result
    return result
  }

  private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean {
    tunneledPlaybackCache[mimeType]?.let { return it }
    val result = try {
      val decoder = MediaCodecQuery.findHardwareDecoder(mimeType) { info, type ->
        info.getCapabilitiesForType(type).isFeatureSupported(
          MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback
        )
      }
      if (decoder != null) {
        Log.d(TAG, "Hardware video decoder ${decoder.name} supports tunneled playback for $mimeType")
        true
      } else {
        Log.d(TAG, "No hardware video decoder supports tunneled playback for $mimeType")
        false
      }
    } catch (e: Exception) {
      Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}")
      false
    }
    tunneledPlaybackCache[mimeType] = result
    return result
  }

  private fun evaluateVideoCodecForTunneling() {
    val player = exoPlayer ?: return
    val selectedVideoGroup = player.currentTracks.groups.firstOrNull {
      it.type == C.TRACK_TYPE_VIDEO && it.isSelected
    } ?: return

    val format = selectedVideoGroup.mediaTrackGroup.getFormat(0)
    val mimeType = format.sampleMimeType ?: return

    val newDisabled = !videoCodecSupportsTunneledPlayback(mimeType)
    if (newDisabled != tunnelingDisabledForVideoCodec) {
      tunnelingDisabledForVideoCodec = newDisabled
      emitLog("info", "tunneling", "Video codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no tunneling support)" else "enabled"}")
    }
  }

  private fun updateTunnelingState(reason: String, forceSelector: Boolean = false) {
    applyTrackSelectorPolicy(reason = reason, forceSelector = forceSelector)
  }

  /** True when [format] is an ASS/SSA subtitle track (rendered by libass). */
  private fun isAssSubtitleFormat(format: Format): Boolean = format.sampleMimeType == MimeTypes.TEXT_SSA || format.codecs == MimeTypes.TEXT_SSA

  /** Sets the ASS-subtitles tunneling block; returns true when the flag changed. */
  private fun updateAssSubtitlesForTunneling(assActive: Boolean): Boolean {
    if (assActive == tunnelingDisabledForAssSubtitles) return false
    tunnelingDisabledForAssSubtitles = assActive
    emitLog(
      "info",
      "tunneling",
      if (assActive) {
        "ASS subtitle track selected: tunneling DISABLED (frame metadata required for libass)"
      } else {
        "ASS subtitle track deselected: tunneling unblocked"
      }
    )
    return true
  }

  private fun evaluateAssSubtitlesForTunneling(tracks: Tracks) {
    val assSelected = tracks.groups.any { group ->
      group.type == C.TRACK_TYPE_TEXT &&
        group.isSelected &&
        (0 until group.length).any { isAssSubtitleFormat(group.getTrackFormat(it)) }
    }
    updateAssSubtitlesForTunneling(assSelected)
  }

  private fun applyTrackSelectorPolicy(
    reason: String,
    forceSelector: Boolean = false,
    clearAudioOverrides: Boolean = false,
    clearTextOverrides: Boolean = false,
    audioOverride: TrackSelectionOverride? = null,
    textOverride: TrackSelectionOverride? = null,
    audioDisabled: Boolean? = null,
    textDisabled: Boolean? = null
  ) {
    val selector = trackSelector ?: return
    val builder = selector.buildUponParameters()
    var selectorChanged = forceSelector

    if (clearAudioOverrides) {
      builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
      selectorChanged = true
    }
    if (clearTextOverrides) {
      builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
      selectorChanged = true
    }
    if (audioOverride != null) {
      builder.setOverrideForType(audioOverride)
      selectorChanged = true
    }
    if (textOverride != null) {
      builder.setOverrideForType(textOverride)
      selectorChanged = true
    }
    if (audioDisabled != null) {
      builder.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, audioDisabled)
      selectorChanged = true
    }
    if (textDisabled != null) {
      builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, textDisabled)
      selectorChanged = true
    }

    val shouldTunnel = calculateTunnelingEnabled() ?: return
    selectorChanged = updateCurrentTunnelingState(reason, shouldTunnel) || selectorChanged
    builder.setTunnelingEnabled(shouldTunnel)
    if (selectorChanged) selector.parameters = builder.build()
  }

  private fun calculateTunnelingEnabled(): Boolean? {
    val player = exoPlayer ?: return null
    val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
    return tunnelingUserEnabled &&
      (player.playbackParameters.speed == 1f) &&
      !tunnelingDisabledForCodec &&
      !tunnelingDisabledForAssSubtitles &&
      !audioDelayActive &&
      !audioNormalizationEnabled
  }

  private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean {
    if (shouldTunnel == currentTunneledPlayback) return false
    currentTunneledPlayback = shouldTunnel
    val speed = exoPlayer?.playbackParameters?.speed ?: 1f
    val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
    emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=$speed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, decodedPcmDisabled=$tunnelingDisabledForDecodedPcm, audioRecoveryDisabled=$tunnelingDisabledForAudioRecovery, assSubtitlesDisabled=$tunnelingDisabledForAssSubtitles, audioDelay=$audioDelayActive, audioNormalization=$audioNormalizationEnabled)")
    return true
  }

  private fun evaluateAudioCodecForTunneling() {
    val player = exoPlayer ?: return
    val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
      it.type == C.TRACK_TYPE_AUDIO && it.isSelected
    } ?: return

    val format = selectedAudioGroup.mediaTrackGroup.getFormat(0)
    updateAudioCodecForTunneling(format)
  }

  private fun updateAudioCodecForTunneling(format: Format) {
    val mimeType = format.sampleMimeType ?: return
    // The decoded-PCM guard only applies to passthrough-type codecs that can be
    // force-decoded; clear it when the selected codec can't have set it. (onAudioTrackInitialized
    // re-sets/clears it for passthrough codecs based on the actual output encoding.)
    if (!isPassthroughAudioMimeType(mimeType)) {
      tunnelingDisabledForDecodedPcm = false
    }

    val newDisabled = !hasHardwareAudioDecoder(mimeType)
    if (newDisabled != tunnelingDisabledForAudioCodec) {
      tunnelingDisabledForAudioCodec = newDisabled
      emitLog("info", "tunneling", "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED (no hw decoder)" else "enabled"}")
    }
  }

  private fun buildMediaItem(uri: String): MediaItem {
    val mediaItemBuilder = MediaItem.Builder()
      .setMediaId(currentMediaGeneration.toString())
      .setUri(uri)

    // Every Live TV backend negotiates HLS before opening the native player.
    // Pin the MIME type so tokenized manifests never fall back to progressive
    // extension sniffing, and so initial opens and recovery use one source path.
    playbackMimeType(currentMediaIsLive)?.let(mediaItemBuilder::setMimeType)

    if (externalSubtitles.isNotEmpty()) {
      mediaItemBuilder.setSubtitleConfigurations(externalSubtitles.toList())
    }

    return mediaItemBuilder.build()
  }

  private fun buildPlaybackMediaSource(uri: String): MediaSource? {
    val factory = playbackMediaSourceFactory ?: return null
    val primarySource = factory.createMediaSource(buildMediaItem(uri))
    if (externalSubtitleContainerUris.isEmpty()) return primarySource

    val sources = mutableListOf<MediaSource>(primarySource)
    externalSubtitleContainerUris.forEach { containerUri ->
      val containerSource = factory.createMediaSource(MediaItem.fromUri(containerUri))
      sources.add(FilteringMediaSource(containerSource, C.TRACK_TYPE_TEXT))
    }
    return MergingMediaSource(
      /* adjustPeriodTimeOffsets = */
      true,
      /* clipDurations = */
      false,
      *sources.toTypedArray()
    )
  }

  private fun setCurrentMediaSource(player: ExoPlayer, uri: String, positionMs: Long) {
    val mediaSource = buildPlaybackMediaSource(uri)
    if (mediaSource == null) {
      player.setMediaItem(buildMediaItem(uri), positionMs)
    } else {
      player.setMediaSource(mediaSource, positionMs)
    }
  }

  private fun selectedAudioFormat(): Format? {
    val player = exoPlayer ?: return null
    val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
      it.type == C.TRACK_TYPE_AUDIO && it.isSelected
    } ?: return null
    return selectedAudioGroup.mediaTrackGroup.getFormat(0)
  }

  private fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
    AudioFormat.ENCODING_PCM_8BIT,
    AudioFormat.ENCODING_PCM_16BIT,
    AudioFormat.ENCODING_PCM_FLOAT,
    AudioFormat.ENCODING_PCM_24BIT_PACKED,
    AudioFormat.ENCODING_PCM_32BIT -> true
    else -> false
  }

  private fun formatAudioSummary(format: Format): String {
    val parts = mutableListOf<String>()
    parts.add("mime=${format.sampleMimeType ?: "unknown"}")
    parts.add("codec=${format.codecs ?: "unknown"}")
    parts.add("channels=${format.channelCount}")
    parts.add("sampleRate=${format.sampleRate}")
    format.id?.let { parts.add("id=$it") }
    return parts.joinToString(", ")
  }

  private fun persistRuntimePlaybackDiagnostics(format: Format? = selectedAudioFormat(), decoderName: String? = null) {
    AndroidRuntimeDiagnostics.update(
      context = activity,
      codecContext = AndroidRuntimeDiagnostics.codecContextForMime(format?.sampleMimeType),
      channelCount = format?.channelCount,
      sampleRate = format?.sampleRate,
      selectedDecoder = decoderName,
      passthroughEnabled = audioPassthroughEnabled,
      downmixEnabled = audioDownmixEnabled,
      normalizationEnabled = audioNormalizationEnabled,
      uiState = AndroidRuntimeDiagnostics.UI_PLAYER
    )
  }

  private fun describeAudioTrackConfig(config: AudioSink.AudioTrackConfig?): String {
    if (config == null) return "none"
    return "encoding=${config.encoding}, channels=${Integer.bitCount(config.channelConfig)}, " +
      "channelConfig=0x${config.channelConfig.toString(16)}, buffer=${config.bufferSize}B, " +
      "tunneling=${config.tunneling}, offload=${config.offload}"
  }

  // Decoder hang detection via AnalyticsListener:
  // Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame.
  // If the decoder is initialized and fed input but never produces output, it's hung
  // (e.g. DV profile 7 on PowerVR GPUs that accept the format but never decode).

  private fun mediaGenerationAt(eventTime: AnalyticsListener.EventTime): Int? {
    val timeline = eventTime.timeline
    val windowIndex = eventTime.windowIndex
    if (windowIndex !in 0 until timeline.windowCount) return null
    return timeline.getWindow(windowIndex, Timeline.Window()).mediaItem.mediaId.toIntOrNull()
  }

  private fun requestFallbackForUnsupportedTracks(tracks: Tracks, mediaGeneration: Int) {
    if (mediaGeneration != currentMediaGeneration) return
    val uri = currentMediaUri ?: return
    val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO }
    val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected }
    if (!hasAnyVideoGroup || hasSelectedVideo) return

    if (retryWithDvConversion("video track not selected")) return
    emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)")
    requestFormatFallback(
      mediaGeneration = mediaGeneration,
      uri = uri,
      positionMs = effectivePosition,
      playWhenReady = exoPlayer?.playWhenReady ?: true,
      errorMessage = "Video track present but no decoder available"
    )
  }

  private fun claimPlaybackOutputReady(): Boolean {
    if (firstFrameRendered) return false
    firstFrameRendered = true
    cancelDecoderHangCheck()
    return true
  }

  private val decoderHangListener = object : AnalyticsListener {
    override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) {
      if (mediaGenerationAt(eventTime) != currentMediaGeneration) return
      handleIsPlayingChanged(isPlaying)
    }

    override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
      if (mediaGenerationAt(eventTime) != currentMediaGeneration) return
      handlePlaybackStateChanged(state)
    }

    override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) {
      val mediaGeneration = mediaGenerationAt(eventTime) ?: return
      if (mediaGeneration != currentMediaGeneration) return
      handlePlayerError(error, mediaGeneration)
    }

    override fun onTracksChanged(eventTime: AnalyticsListener.EventTime, tracks: Tracks) {
      val mediaGeneration = mediaGenerationAt(eventTime) ?: return
      requestFallbackForUnsupportedTracks(tracks, mediaGeneration)
    }

    override fun onVideoDecoderInitialized(
      eventTime: AnalyticsListener.EventTime,
      decoderName: String,
      initializationDurationMs: Long
    ) {
      val mediaGeneration = mediaGenerationAt(eventTime) ?: return
      if (mediaGeneration != currentMediaGeneration) return
      decoderInitName = decoderName
      persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName)
      firstFrameRendered = false
      emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
      logDolbyVisionPlaybackPathIfNeeded(decoderName)
      startDecoderHangCheck(decoderName, mediaGeneration)
    }

    override fun onAudioDecoderInitialized(
      eventTime: AnalyticsListener.EventTime,
      decoderName: String,
      initializationDurationMs: Long
    ) {
      audioDecoderInitName = decoderName
      persistRuntimePlaybackDiagnostics(decoderName = decoderName)
      emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
    }

    override fun onAudioInputFormatChanged(
      eventTime: AnalyticsListener.EventTime,
      format: Format,
      decoderReuseEvaluation: DecoderReuseEvaluation?
    ) {
      persistRuntimePlaybackDiagnostics(format, audioDecoderInitName)
      emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
      if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
        updateAudioDecoderPolicy("input format", format)
      }
    }

    override fun onAudioPositionAdvancing(
      eventTime: AnalyticsListener.EventTime,
      playoutStartSystemTimeMs: Long
    ) {
      emitLog("debug", "audio", "Position advancing: playoutStart=$playoutStartSystemTimeMs")
    }

    override fun onAudioUnderrun(
      eventTime: AnalyticsListener.EventTime,
      bufferSize: Int,
      bufferSizeMs: Long,
      elapsedSinceLastFeedMs: Long
    ) {
      emitLog(
        "warn",
        "audio",
        "Underrun: buffer=${bufferSize}B, bufferMs=$bufferSizeMs, elapsedSinceLastFeedMs=$elapsedSinceLastFeedMs"
      )
    }

    override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
      lastAudioSinkError = "${audioSinkError.javaClass.name}: ${audioSinkError.message}"
      emitLog("warn", "audio", "Sink error: ${audioSinkError.javaClass.name}: ${audioSinkError.message}")
    }

    override fun onAudioCodecError(eventTime: AnalyticsListener.EventTime, audioCodecError: Exception) {
      emitLog("warn", "audio", "Codec error: ${audioCodecError.javaClass.name}: ${audioCodecError.message}")
    }

    override fun onAudioTrackInitialized(
      eventTime: AnalyticsListener.EventTime,
      audioTrackConfig: AudioSink.AudioTrackConfig
    ) {
      lastAudioTrackConfig = audioTrackConfig
      val audioFormat = selectedAudioFormat()
      persistRuntimePlaybackDiagnostics(audioFormat, audioDecoderInitName ?: "direct")
      emitLog(
        "info",
        "audio",
        "AudioTrack initialized: input=${audioFormat?.let { formatAudioSummary(it) } ?: "unknown"}, " +
          "decoder=${audioDecoderInitName ?: "direct/bypass"}, ${describeAudioTrackConfig(audioTrackConfig)}"
      )
      // A passthrough-type codec (AC3/EAC3/DTS/TrueHD…) force-decoded to PCM must not
      // stay on a tunneled AudioTrack: some Amlogic/AOSP boxes deliver the PCM frames
      // but render silence (#1458). Generalizes the original TrueHD-only guard to every
      // codec the passthrough block (and #1289 normalization) can force-decode.
      val passthroughSrcMime = audioFormat?.sampleMimeType?.takeIf { isPassthroughAudioMimeType(it) }
      if (passthroughSrcMime != null) {
        if (audioTrackConfig.tunneling && isPcmEncoding(audioTrackConfig.encoding)) {
          if (!loggedDecodedPcmTunnelingGuard) {
            loggedDecodedPcmTunnelingGuard = true
            emitLog("warn", "audio", "Decoded $passthroughSrcMime PCM initialized with tunneling=true; forcing tunneling off")
          }
          tunnelingDisabledForDecodedPcm = true
          updateTunnelingState("decoded PCM tunneling guard", forceSelector = true)
        } else if (!isPcmEncoding(audioTrackConfig.encoding) && tunnelingDisabledForDecodedPcm) {
          tunnelingDisabledForDecodedPcm = false
          updateTunnelingState("encoded passthrough output initialized", forceSelector = true)
        }
      }
      // Re-key the normalization effect to the actual output channel count
      // (DynamicsProcessing parameters are per-channel).
      if (audioNormalizationEnabled) attachNormalizationEffect()
    }

    override fun onAudioSessionIdChanged(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) {
      emitLog("debug", "audio", "Audio session id: $audioSessionId")
      if (audioNormalizationEnabled) attachNormalizationEffect()
    }

    override fun onAudioTrackReleased(
      eventTime: AnalyticsListener.EventTime,
      audioTrackConfig: AudioSink.AudioTrackConfig
    ) {
      emitLog(
        "debug",
        "audio",
        "AudioTrack released: encoding=${audioTrackConfig.encoding}, sampleRate=${audioTrackConfig.sampleRate}, " +
          "channelConfig=0x${audioTrackConfig.channelConfig.toString(16)}, buffer=${audioTrackConfig.bufferSize}B"
      )
    }

    override fun onVolumeChanged(eventTime: AnalyticsListener.EventTime, volume: Float) {
      emitLog("debug", "audio", "Volume changed: $volume")
    }

    override fun onRenderedFirstFrame(
      eventTime: AnalyticsListener.EventTime,
      output: Any,
      renderTimeMs: Long
    ) {
      val mediaGeneration = mediaGenerationAt(eventTime) ?: return
      if (mediaGeneration != currentMediaGeneration) return
      hasRenderedVideoFrameForMedia = true
      if (!claimPlaybackOutputReady()) return
      emitLog("debug", "decoder-hang", "First frame rendered — decoder OK")
      logNativeDvFirstFrameIfNeeded()
      logDolbyVisionPlaybackPathIfNeeded()
      // STATE_READY fires when the player has enough buffered to start, but
      // the first frame may not be on screen yet (decoder init + keyframe
      // decode). The MPV-parity `playback-restart` event consumers (Dart
      // first-frame detection, frame-rate matching) want the moment the
      // pixel actually hits the screen, which is here.
      delegate?.onEvent("playback-restart", null)
    }
  }

  private fun startDecoderHangCheck(decoderName: String, mediaGeneration: Int) {
    cancelDecoderHangCheck()
    if (mediaGeneration != currentMediaGeneration || currentMediaUri == null) return
    decoderHangRunnable = Runnable {
      if (mediaGeneration != currentMediaGeneration) return@Runnable
      if (firstFrameRendered) return@Runnable
      val uri = currentMediaUri ?: return@Runnable
      val player = exoPlayer ?: return@Runnable

      // Confirm via DecoderCounters: input queued but no output produced
      val counters = player.videoDecoderCounters
      val inputQueued = counters?.queuedInputBufferCount ?: 0
      val outputTotal = (counters?.renderedOutputBufferCount ?: 0) +
        (counters?.skippedOutputBufferCount ?: 0) +
        (counters?.droppedBufferCount ?: 0)

      if (inputQueued > 0 && outputTotal == 0) {
        emitLog("warn", "fallback", "Decoder hang: $decoderName queued $inputQueued buffers, 0 output after ${DECODER_HANG_TIMEOUT_MS}ms")
        stopFrameWatchdog()
        cancelDecoderHangCheck()
        if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable
        if (retryVideoDecoderInPlace("decoder hang: $decoderName")) return@Runnable
        requestFormatFallback(
          mediaGeneration = mediaGeneration,
          uri = uri,
          positionMs = effectivePosition,
          playWhenReady = player.playWhenReady,
          errorMessage = "Decoder hang: $decoderName accepted input but produced no output"
        )
      }
    }
    handler.postDelayed(decoderHangRunnable!!, DECODER_HANG_TIMEOUT_MS)
  }

  private fun cancelDecoderHangCheck() {
    decoderHangRunnable?.let { handler.removeCallbacks(it) }
    decoderHangRunnable = null
  }

  // Frame watchdog: detects when ExoPlayer plays audio but renders 0 video frames
  // (common with HDR tunneling on unsupported devices — black screen, no error)

  private fun frameWatchdogElapsedMs(nowMs: Long, isPlaying: Boolean): Long {
    if (!isPlaying) frameWatchdogStartTime = nowMs
    return nowMs - frameWatchdogStartTime
  }

  private fun startFrameWatchdog() {
    stopFrameWatchdog()
    emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)")
    frameWatchdogStartTime = System.currentTimeMillis()
    val mediaGeneration = currentMediaGeneration
    val initialCounters = exoPlayer?.videoDecoderCounters
    val initialRenderedFrames = initialCounters?.renderedOutputBufferCount ?: 0
    frameWatchdogRunnable = object : Runnable {
      override fun run() {
        if (mediaGeneration != currentMediaGeneration) return
        val player = exoPlayer ?: return
        val counters = player.videoDecoderCounters
        val renderedFrames = if (counters === initialCounters) {
          (counters?.renderedOutputBufferCount ?: 0) - initialRenderedFrames
        } else {
          counters?.renderedOutputBufferCount ?: 0
        }

        if (renderedFrames > 0) {
          emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared")
          stopFrameWatchdog()
          return
        }

        val elapsed = frameWatchdogElapsedMs(System.currentTimeMillis(), player.isPlaying)

        // Check if we have a video track selected
        val hasVideoTrack = player.currentTracks.groups.any {
          it.type == C.TRACK_TYPE_VIDEO && it.isSelected
        }
        val hasAnyVideoGroup = player.currentTracks.groups.any {
          it.type == C.TRACK_TYPE_VIDEO
        }

        // Secondary safety net: video track exists but was deselected (unsupported codec)
        if (hasAnyVideoGroup && !hasVideoTrack) {
          emitLog("warn", "watchdog", "Video track deselected — triggering fallback")
          stopFrameWatchdog()
          if (retryWithDvConversion("watchdog: video track deselected")) return
          val uri = currentMediaUri ?: return
          requestFormatFallback(
            mediaGeneration = mediaGeneration,
            uri = uri,
            positionMs = player.currentPosition,
            playWhenReady = player.playWhenReady,
            errorMessage = "Video track present but no decoder available"
          )
          return
        }

        if (elapsed >= WATCHDOG_TIMEOUT_MS && player.isPlaying && hasVideoTrack) {
          emitLog("warn", "watchdog", "0 frames rendered after ${elapsed}ms — triggering fallback")
          stopFrameWatchdog()
          if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return
          if (retryVideoDecoderInPlace("black screen after ${elapsed}ms")) return
          // Trigger fallback via the same delegate path as player errors
          val uri = currentMediaUri ?: return
          requestFormatFallback(
            mediaGeneration = mediaGeneration,
            uri = uri,
            positionMs = player.currentPosition,
            playWhenReady = player.playWhenReady,
            errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms"
          )
          return
        }

        handler.postDelayed(this, WATCHDOG_CHECK_INTERVAL_MS)
      }
    }
    handler.postDelayed(frameWatchdogRunnable!!, WATCHDOG_CHECK_INTERVAL_MS)
  }

  private fun stopFrameWatchdog() {
    frameWatchdogRunnable?.let { handler.removeCallbacks(it) }
    frameWatchdogRunnable = null
  }

  // Post-resume video stall watchdog (#1454) — see the field comment. Decision
  // logic lives in ResumeStallPolicy; this wiring is main-thread only, like the
  // frame watchdog above.

  private fun armResumeStallWatchdog() {
    cancelResumeStallCheck()
    val player = exoPlayer ?: return
    if (resumeStallRecoveryCount >= ResumeStallPolicy.MAX_RECOVERIES_PER_SESSION) {
      if (!loggedResumeStallCap) {
        loggedResumeStallCap = true
        emitLog("warn", "resume-stall", "Recovery cap reached ($resumeStallRecoveryCount) — watchdog disabled for this session")
      }
      return
    }
    // A cold decoder (0 frames ever rendered) is the startup watchdog's territory.
    val baselineFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
    if (baselineFrames <= 0) return
    val hasVideoTrack = player.currentTracks.groups.any {
      it.type == C.TRACK_TYPE_VIDEO && it.isSelected
    }
    if (!hasVideoTrack) return

    resumeStallBaselineFrames = baselineFrames
    resumeStallBaselinePositionMs = player.currentPosition
    resumeStallRechecksLeft = ResumeStallPolicy.MAX_RECHECKS
    val windowMs = ResumeStallPolicy.checkWindowMs(
      currentVideoFormat?.frameRate,
      detectedFrameRate,
      player.playbackParameters.speed
    )
    emitLog("debug", "resume-stall", "Armed (baselineFrames=$baselineFrames, windowMs=$windowMs)")
    resumeStallRunnable = Runnable { checkResumeStall(windowMs) }
    handler.postDelayed(resumeStallRunnable!!, windowMs)
  }

  /** Cancels a pending stall check but keeps a recovery verification alive: the
   * recovery seek itself flickers isPlaying through buffering, which must not
   * silence its own confirmation log. */
  private fun cancelResumeStallCheck() {
    resumeStallRunnable?.let { handler.removeCallbacks(it) }
    resumeStallRunnable = null
  }

  private fun cancelResumeStallWatchdog() {
    cancelResumeStallCheck()
    resumeStallVerifyRunnable?.let { handler.removeCallbacks(it) }
    resumeStallVerifyRunnable = null
  }

  private fun checkResumeStall(windowMs: Long) {
    resumeStallRunnable = null
    if (disposing || !isInitialized) return
    val player = exoPlayer ?: return
    if (!player.isPlaying || player.playbackState != Player.STATE_READY) return
    val hasVideoTrack = player.currentTracks.groups.any {
      it.type == C.TRACK_TYPE_VIDEO && it.isSelected
    }
    if (!hasVideoTrack) return
    val currentFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return

    val verdict = ResumeStallPolicy.evaluate(
      baselineFrames = resumeStallBaselineFrames,
      currentFrames = currentFrames,
      baselinePositionMs = resumeStallBaselinePositionMs,
      currentPositionMs = player.currentPosition,
      durationMs = player.duration,
      windowMs = windowMs
    )
    when (verdict) {
      ResumeStallPolicy.Verdict.HEALTHY ->
        emitLog("debug", "resume-stall", "Cleared (frames $resumeStallBaselineFrames→$currentFrames)")
      ResumeStallPolicy.Verdict.SKIP_NEAR_EOF ->
        emitLog("debug", "resume-stall", "Skipped (near end of stream)")
      ResumeStallPolicy.Verdict.RECHECK -> {
        if (resumeStallRechecksLeft-- > 0) {
          resumeStallRunnable = Runnable { checkResumeStall(windowMs) }
          handler.postDelayed(resumeStallRunnable!!, windowMs)
        } else {
          emitLog("debug", "resume-stall", "Gave up (clock not advancing — not a decoder stall)")
        }
      }
      ResumeStallPolicy.Verdict.STALLED -> recoverFromResumeStall(player, windowMs, currentFrames)
    }
  }

  private fun recoverFromResumeStall(player: ExoPlayer, windowMs: Long, stalledFrames: Int) {
    resumeStallRecoveryCount++
    val positionMs = player.currentPosition
    // The clock-advance guard guarantees positionMs ≥ windowMs/2 here, so the
    // target never resolves to the current position (which ExoPlayer would
    // short-circuit without the codec flush this recovery exists for).
    val targetMs = (positionMs - ResumeStallPolicy.SEEK_BACK_MS).coerceAtLeast(0L)
    emitLog(
      "warn",
      "resume-stall",
      "Video frozen after resume: no new frames in ${windowMs}ms at ${positionMs}ms " +
        "(frames=$stalledFrames, tunneling=$currentTunneledPlayback, decoder=${decoderInitName ?: "unknown"}, " +
        "model=${Build.MODEL}) — recovering with seek to ${targetMs}ms " +
        "($resumeStallRecoveryCount/${ResumeStallPolicy.MAX_RECOVERIES_PER_SESSION})"
    )
    seekTo(targetMs)
    resumeStallVerifyRunnable = Runnable { verifyResumeStallRecovery(stalledFrames, windowMs) }
    handler.postDelayed(resumeStallVerifyRunnable!!, windowMs)
  }

  private fun verifyResumeStallRecovery(stalledFrames: Int, windowMs: Long) {
    resumeStallVerifyRunnable = null
    if (disposing || !isInitialized) return
    val player = exoPlayer ?: return
    if (!player.isPlaying) return // paused or reloaded since; nothing to verify
    val frames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
    if (frames != stalledFrames) {
      emitLog("info", "resume-stall", "Recovery confirmed: video frames advancing again (frames $stalledFrames→$frames)")
    } else {
      emitLog("warn", "resume-stall", "Recovery seek did not restart video frames (still $frames after ${windowMs}ms)")
    }
  }

  // Public API

  fun open(
    uri: String,
    headers: Map<String, String>?,
    startPositionMs: Long,
    autoPlay: Boolean,
    mediaGeneration: Int,
    isLive: Boolean = false,
    externalSubtitleList: List<Map<String, Any?>>? = null
  ) {
    if (!isInitialized) return

    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    resumeStallRecoveryCount = 0
    loggedResumeStallCap = false

    // Reset FPS detection for new content
    detectedFrameRate = -1f
    fpsTimestampCount = 0
    assSyncFrameCount = 0

    // Reset DV7 retry flag when opening a different file
    if (uri != currentMediaUri) {
      dv7RetryAttempted = false
    }

    decoderInitName = null
    audioDecoderInitName = null
    lastAudioTrackConfig = null
    lastAudioSinkError = null
    audioRecoveryAttempts = 0
    lastAudioRecoveryAction = null
    lastAudioRecoveryReason = null
    directAudioOutputBlockedAfterFailure.clear()
    loggedDirectAudioRecoveryBlocks.clear()
    currentVideoFormat = null
    firstFrameRendered = false
    hasRenderedVideoFrameForMedia = false
    videoDecoderRecoveryConsecutiveAttempts = 0
    videoDecoderRecoveryTotalAttempts = 0
    videoDecoderRecoveryPositionMs = null
    loggedNativeDvSelectionKey = null
    loggedNativeDvFirstFrame = false
    loggedDvPlaybackPathKey = null
    lastDvPlaybackInfo = null
    loggedDecodedPcmTunnelingGuard = false
    updateAudioDecoderPolicy("open")
    currentMediaGeneration = mediaGeneration
    terminalErrorGeneration = null
    currentMediaUri = uri
    currentHeaders = headers
    currentMediaIsLive = isLive
    resetPlaybackProgress(startPositionMs)

    // Apply auth/custom headers to the HTTP DataSource for this session
    httpDataSourceFactory?.setDefaultRequestProperties(
      if (!headers.isNullOrEmpty()) headers else emptyMap()
    )

    externalSubtitles.clear()
    externalSubtitleUris.clear()
    externalSubtitleContainerUris.clear()
    lastSubtitleCues = emptyList()
    hadSelectedTextTrack = false
    audioTrackGroupMap.clear()
    subtitleTrackGroupMap.clear()
    selectedAudioTrackId = null
    selectedSubtitleTrackId = null
    pendingTrackRestore = null

    // Build external subtitle sources before prepare. Container sidecars are
    // filtered to text tracks and merged with the primary source; standalone
    // subtitle files continue to use MediaItem subtitle configurations.
    externalSubtitleList?.forEach { sub ->
      val subUri = sub["uri"] as? String ?: return@forEach
      if (subUri.isBlank()) return@forEach
      if (sub["isContainer"] as? Boolean == true) {
        if (!externalSubtitleContainerUris.contains(subUri)) {
          externalSubtitleContainerUris.add(subUri)
        }
        return@forEach
      }

      val index = externalSubtitles.size
      val title = sub["title"] as? String
      val language = sub["language"] as? String
      val codec = sub["codec"] as? String
      val mimeType = sub["mimeType"] as? String
      val isDefault = sub["isDefault"] as? Boolean ?: false
      val isForced = sub["isForced"] as? Boolean ?: false
      val selectionFlags =
        (if (isDefault) C.SELECTION_FLAG_DEFAULT else 0) or
          (if (isForced) C.SELECTION_FLAG_FORCED else 0)
      val config = MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUri))
        .setId("external_$index")
        .setLabel(title ?: "External")
        .setLanguage(language)
        .setMimeType(mimeType ?: subtitleMimeTypeForCodec(codec) ?: detectSubtitleMimeType(subUri))
        .setSelectionFlags(selectionFlags)
        .build()
      externalSubtitles.add(config)
      externalSubtitleUris.add(subUri)
    }
    tunnelingDisabledForAudioCodec = false
    tunnelingDisabledForVideoCodec = false
    tunnelingDisabledForDecodedPcm = false
    tunnelingDisabledForAudioRecovery = false
    tunnelingDisabledForAssSubtitles = false
    currentTunneledPlayback = false
    // audioNormalizationEnabled persists across opens (user-level state, like
    // tunnelingUserEnabled); only the in-flight bounce is abandoned.
    pendingAudioRendererBounce = false
    AndroidRuntimeDiagnostics.clearPlayback(activity)
    persistRuntimePlaybackDiagnostics()
    handler.removeCallbacks(audioBounceTimeout)
    pendingStartPositionMs = startPositionMs
    pendingPlayWhenReady = autoPlay
    applyTrackSelectorPolicy(
      reason = "open",
      forceSelector = true,
      clearAudioOverrides = true,
      clearTextOverrides = true,
      textDisabled = true
    )
    emitSeekable(false, force = true)

    exoPlayer?.apply {
      setCurrentMediaSource(this, uri, startPositionMs)
      prepare()
      playWhenReady = autoPlay
    }

    val sourceLabel = if (isLive) "live HLS" else "media"
    emitLog("info", "media", "Opened $sourceLabel: ${redactUri(uri)}, startPosition: ${startPositionMs}ms, autoPlay: $autoPlay, sessionTunneling=$currentTunneledPlayback, userTunneling=$tunnelingUserEnabled")
  }

  fun setAudioDelay(seconds: Double) {
    renderersFactory?.audioDelayUs?.set((seconds * 1_000_000).toLong())
    updateTunnelingState("audio-delay")
  }

  fun setAudioNormalization(enabled: Boolean) {
    if (audioNormalizationEnabled == enabled) return
    audioNormalizationEnabled = enabled
    persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
    emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}")
    if (enabled) attachNormalizationEffect() else audioNormalization.release()

    if (exoPlayer == null) return
    // A selector-parameter change only re-inits the audio renderer when the
    // parameters actually differ (the tunneling flag flipping). Otherwise the
    // renderer keeps its bypass/decode path and the sink's new direct-output
    // verdict is never consulted — bounce the renderer in that case.
    val tunnelingWillFlip = calculateTunnelingEnabled() != currentTunneledPlayback
    val outputEncoding = lastAudioTrackConfig?.encoding
    val selectedMime = selectedAudioFormat()?.sampleMimeType
    // While downmix holds the output on decoded PCM the verdict cannot flip.
    val needsBounce = !tunnelingWillFlip &&
      !audioDownmixEnabled &&
      outputEncoding != null &&
      selectedMime != null &&
      isEncodedAudioMimeType(selectedMime) &&
      (if (enabled) !isPcmEncoding(outputEncoding) else isPcmEncoding(outputEncoding))
    if (needsBounce) {
      startAudioRendererBounce("audio-normalization")
    } else {
      updateTunnelingState("audio-normalization")
    }
  }

  fun setAudioPassthrough(enabled: Boolean) {
    if (audioPassthroughEnabled == enabled) return
    audioPassthroughEnabled = enabled
    persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
    emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}")

    if (exoPlayer == null) return
    val outputEncoding = lastAudioTrackConfig?.encoding
    val selectedMime = selectedAudioFormat()?.sampleMimeType
    // While downmix holds the output on decoded PCM the verdict cannot flip.
    val needsBounce = !audioDownmixEnabled &&
      outputEncoding != null &&
      selectedMime != null &&
      isPassthroughAudioMimeType(selectedMime) &&
      (if (enabled) isPcmEncoding(outputEncoding) else !isPcmEncoding(outputEncoding))
    if (needsBounce) {
      startAudioRendererBounce("audio-passthrough")
    } else {
      updateTunnelingState("audio-passthrough")
    }
  }

  fun setAudioDownmix(enabled: Boolean, centerBoostDb: Int, normalize: Boolean) {
    val boost = centerBoostDb.coerceIn(0, DownmixMatrices.MAX_CENTER_BOOST_DB)
    val enabledChanged = audioDownmixEnabled != enabled
    if (!enabledChanged && audioDownmixCenterBoostDb == boost && audioDownmixNormalize == normalize) return
    audioDownmixEnabled = enabled
    audioDownmixCenterBoostDb = boost
    audioDownmixNormalize = normalize
    persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
    emitLog(
      "info",
      "audio-downmix",
      if (enabled) "Stereo downmix enabled (centerBoost=${boost}dB, normalize=$normalize)" else "Stereo downmix disabled"
    )
    applyDownmixMatrices()
    if (exoPlayer == null || !enabledChanged) return
    // Before the first audio track is up (apply-at-open) the initial sink
    // configure picks the matrices up on its own; no bounce needed.
    if (lastAudioTrackConfig == null) return
    // The processor's active/inactive state (identity vs downmix matrix) and
    // the sink's direct-output verdict are both latched at configure time —
    // bounce the renderer to re-evaluate. Coefficient-only changes are picked
    // up live by queueInput without a bounce.
    startAudioRendererBounce("audio-downmix")
  }

  private fun applyDownmixMatrices() {
    val processor = renderersFactory?.channelMixProcessor ?: return
    for (count in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
      val coefficients = if (audioDownmixEnabled) {
        DownmixMatrices.stereoCoefficients(count, audioDownmixCenterBoostDb, audioDownmixNormalize)
      } else {
        null
      }
      processor.putChannelMixingMatrix(
        if (coefficients != null) {
          ChannelMixingMatrix(count, 2, coefficients)
        } else {
          renderersFactory?.identityChannelMixingMatrix(count) ?: return
        }
      )
    }
  }

  private fun attachNormalizationEffect() {
    val sessionId = exoPlayer?.audioSessionId ?: C.AUDIO_SESSION_ID_UNSET
    if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
      emitLog("debug", "audio-normalization", "Audio session id not ready; attach deferred")
      return // onAudioSessionIdChanged re-attaches
    }
    val channels = lastAudioTrackConfig?.channelConfig?.let { Integer.bitCount(it) }
    audioNormalization.attach(sessionId, channels)
  }

  // Two-phase audio renderer bounce: disable, wait for the playback thread to
  // observe it (onTracksChanged with no selected audio), then re-enable so track
  // selection re-queries the sink's format support. A synchronous flip-back
  // would be coalesced: the invalidation message reads the latest parameters.
  private fun startAudioRendererBounce(reason: String) {
    if (pendingAudioRendererBounce) return
    pendingAudioRendererBounce = true
    emitLog("info", "audio", "Bouncing audio renderer to re-evaluate output path (reason=$reason)")
    applyTrackSelectorPolicy(reason = "$reason (audio renderer off)", audioDisabled = true)
    handler.postDelayed(audioBounceTimeout, AUDIO_BOUNCE_TIMEOUT_MS)
  }

  private fun completeAudioRendererBounce(reason: String) {
    if (!pendingAudioRendererBounce) return
    pendingAudioRendererBounce = false
    handler.removeCallbacks(audioBounceTimeout)
    applyTrackSelectorPolicy(reason = reason, audioDisabled = false)
  }

  fun setSubtitleDelay(seconds: Double) {
    subtitleDelayUs.set((seconds * 1_000_000).toLong())
  }

  fun setAssVideoLatencyFrames(frames: Int) {
    assVideoLatencyFrames = frames.coerceIn(-2, 2)
  }

  /**
   * Seed the subtitle/video layer offset at init: prefer this device's persisted measured
   * calibration, falling back to [proxyDefault] (the Dart perf-tier guess) on first-ever play.
   * The live [AssLatencyCalibrator] re-confirms and updates it each play.
   */
  fun seedAssVideoLatencyFrames(proxyDefault: Int) {
    val stored = activity.getSharedPreferences(ASS_CAL_PREFS, Context.MODE_PRIVATE)
      .getInt(ASS_CAL_KEY_FRAMES, Int.MIN_VALUE)
    val seed = if (stored != Int.MIN_VALUE) stored else proxyDefault
    setAssVideoLatencyFrames(seed)
    Log.d(TAG, "ass latency seed=$seed (stored=${if (stored == Int.MIN_VALUE) "none" else stored}, proxy=$proxyDefault)")
  }

  /** Apply + persist a freshly measured calibration (called by [AssLatencyCalibrator]). */
  private fun onAssLatencyCalibrated(frames: Int) {
    val clamped = frames.coerceIn(-2, 2)
    val previous = assVideoLatencyFrames
    setAssVideoLatencyFrames(clamped)
    activity.getSharedPreferences(ASS_CAL_PREFS, Context.MODE_PRIVATE)
      .edit().putInt(ASS_CAL_KEY_FRAMES, clamped).apply()
    if (clamped != previous) {
      emitLog("info", "ass-latency-cal", "applied offsetFrames=$clamped (was $previous), persisted")
    }
  }

  fun setDebugDvConversionMode(mode: String): Boolean {
    val override = when (mode.trim().lowercase()) {
      "auto" -> null
      "disabled", "native" -> DvConversionMode.DISABLED
      "dv81", "p8", "p7_to_p8", "p7-to-p8" -> DvConversionMode.DV81
      "hevc", "hevc_strip", "p7_to_hevc", "p7-to-hevc" -> DvConversionMode.HEVC_STRIP
      else -> return false
    }

    debugDvModeOverride = override
    dvMode = getConfiguredDvMode()
    dv7RetryAttempted = override != null
    activeDoviMkvWrapper = null
    activeDoviMp4Wrapper = null
    val debugMode = override?.name ?: "AUTO"
    emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)")
    reloadCurrentMediaForDvMode()
    return true
  }

  private fun reloadCurrentMediaForDvMode(): Boolean {
    val player = exoPlayer ?: return false
    val uri = currentMediaUri ?: return false
    if (currentMediaIsLive) return false

    val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
    val savedPlayWhenReady = player.playWhenReady
    pendingTrackRestore = captureTrackRestore()?.also { restore ->
      emitLog(
        "info",
        "track-restore",
        "Saved selection before DV reload: audio=${restore.audio?.describe() ?: "none"}, " +
          "subtitle=${restore.subtitle?.describe() ?: if (restore.subtitleDisabled) "off" else "none"}"
      )
    }
    pendingStartPositionMs = savedPosition
    pendingPlayWhenReady = savedPlayWhenReady
    decoderInitName = null
    audioDecoderInitName = null
    detectedFrameRate = -1f
    fpsTimestampCount = 0
    assSyncFrameCount = 0
    firstFrameRendered = false
    currentVideoFormat = null
    loggedNativeDvSelectionKey = null
    loggedNativeDvFirstFrame = false
    loggedDvPlaybackPathKey = null
    lastDvPlaybackInfo = null
    activeDoviMkvWrapper = null
    activeDoviMp4Wrapper = null
    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()

    applyTrackSelectorPolicy(
      reason = "DV reload",
      forceSelector = true,
      clearAudioOverrides = true,
      clearTextOverrides = true
    )

    setCurrentMediaSource(player, uri, savedPosition)
    player.prepare()
    player.playWhenReady = savedPlayWhenReady
    emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
    return true
  }

  fun play() {
    pendingPlayWhenReady = null
    exoPlayer?.play()
  }

  fun pause() {
    pendingPlayWhenReady = null
    exoPlayer?.pause()
  }

  fun stop() {
    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    exoPlayer?.stop()
    emitSeekable(false, force = true)
    setVisible(false)
  }

  fun seekTo(positionMs: Long) {
    val player = exoPlayer ?: return
    val durationMs = player.duration
    val clampedPositionMs = if (!currentMediaIsLive && durationMs != C.TIME_UNSET && durationMs > 0L) {
      positionMs.coerceIn(0L, durationMs)
    } else {
      positionMs.coerceAtLeast(0L)
    }
    // A user seek is authoritative. Do not let a pending start-position restore
    // from open/reload recovery re-seek back over an early seek near zero once
    // ExoPlayer reports STATE_READY.
    pendingStartPositionMs = 0L
    player.seekTo(clampedPositionMs)
    lastPosition = clampedPositionMs
    delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
  }

  fun setVolume(volume: Float) {
    exoPlayer?.volume = volume.coerceIn(0f, 1f)
    delegate?.onPropertyChange("volume", (volume * 100).toDouble())
  }

  fun setPlaybackSpeed(speed: Float) {
    val clampedSpeed = speed.coerceIn(MIN_PLAYBACK_SPEED, MAX_PLAYBACK_SPEED)
    exoPlayer?.setPlaybackSpeed(clampedSpeed)
    updateTunnelingState("speed changed")
    delegate?.onPropertyChange("speed", clampedSpeed.toDouble())
  }

  fun selectAudioTrack(trackId: String) {
    val trackGroup = audioTrackGroupMap[trackId] ?: return
    val format = trackGroup.getFormat(0)
    updateAudioCodecForTunneling(format)

    applyTrackSelectorPolicy(
      reason = "audio track selected",
      audioOverride = TrackSelectionOverride(trackGroup, 0),
      audioDisabled = false
    )

    selectedAudioTrackId = trackId
    delegate?.onPropertyChange("aid", trackId)
  }

  fun selectSubtitleTrack(trackId: String?) {
    if (trackId == null || trackId == "no") {
      selectedSubtitleTrackId = "no"
      // Flip the tunneling block in the same parameters update as the text
      // disable so the renderer re-initializes once, not twice.
      updateAssSubtitlesForTunneling(false)
      applyTrackSelectorPolicy(
        reason = "subtitle disabled",
        textDisabled = true
      )
      delegate?.onPropertyChange("sid", "no")
      return
    }

    val trackGroup = subtitleTrackGroupMap[trackId] ?: return
    selectedSubtitleTrackId = trackId
    updateAssSubtitlesForTunneling(isAssSubtitleFormat(trackGroup.getFormat(0)))
    applyTrackSelectorPolicy(
      reason = "subtitle track selected",
      textOverride = TrackSelectionOverride(trackGroup, 0),
      textDisabled = false
    )
    delegate?.onPropertyChange("sid", trackId)
  }

  fun addSubtitleTrack(uri: String, title: String?, language: String?, mimeType: String?, select: Boolean) {
    val existingIndex = externalSubtitleUris.indexOf(uri)
    val isNew = existingIndex < 0
    val index = if (isNew) externalSubtitles.size else existingIndex
    val formatId = "external_$index"

    if (isNew) {
      // SELECTION_FLAG_DEFAULT marks this as the preferred text track so ExoPlayer's
      // natural selection picks it on prepare. Avoids pinning the selector to a
      // specific TrackGroup override — if the URL 404s (e.g. stale Plex stream key),
      // ExoPlayer falls back to another available track (e.g. embedded SRT) instead
      // of leaving text disabled.
      val selectionFlags = if (select) C.SELECTION_FLAG_DEFAULT else 0
      val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(uri))
        .setId(formatId)
        .setLabel(title ?: "External")
        .setLanguage(language)
        .setMimeType(mimeType ?: detectSubtitleMimeType(uri))
        .setSelectionFlags(selectionFlags)
        .build()
      externalSubtitles.add(subtitleConfig)
      externalSubtitleUris.add(uri)
    }

    // Media3 only picks up MediaItem.SubtitleConfiguration at prepare() time
    // (tracking issue androidx/media #1649). When the caller wants this subtitle
    // activated immediately (e.g. after OpenSubtitles download), rebuild the
    // MediaItem and re-prepare with the position preserved.
    val player = exoPlayer
    val mediaUri = currentMediaUri
    if (select && player != null && mediaUri != null && !currentMediaIsLive) {
      if (isNew) {
        val savedPosition = player.currentPosition
        val savedPlayWhenReady = player.playWhenReady

        // Clear any stale text-type override (pointing at a pre-reload TrackGroup)
        // and re-enable the text type — mirrors the reset done in open(). Without
        // this, a previously-selected sub's override would either block the new
        // DEFAULT-flagged sub from winning or, if the new sub fails to load, keep
        // the text renderer stuck with no selection.
        applyTrackSelectorPolicy(
          reason = "external subtitle reload",
          clearTextOverrides = true,
          textDisabled = false
        )
        selectedSubtitleTrackId = null

        setCurrentMediaSource(player, mediaUri, savedPosition)
        player.prepare()
        player.playWhenReady = savedPlayWhenReady
      } else {
        // Already attached — select the existing track via override.
        val trackId = subtitleTrackGroupMap.entries
          .firstOrNull { (_, group) -> group.getFormat(0).id == formatId }
          ?.key
        if (trackId != null) {
          selectSubtitleTrack(trackId)
        }
      }
    }

    if (isNew) emitTrackList()
  }

  private fun detectSubtitleMimeType(uri: String): String {
    // Strip query params before checking extension (Plex URLs have ?X-Plex-Token=...)
    val path = Uri.parse(uri).path?.lowercase() ?: uri.lowercase()
    return when {
      path.endsWith(".srt") -> MimeTypes.APPLICATION_SUBRIP
      path.endsWith(".ass") || path.endsWith(".ssa") -> MimeTypes.TEXT_SSA
      path.endsWith(".vtt") -> MimeTypes.TEXT_VTT
      path.endsWith(".ttml") -> MimeTypes.APPLICATION_TTML
      else -> MimeTypes.APPLICATION_SUBRIP
    }
  }

  private fun subtitleMimeTypeForCodec(codec: String?): String? = when (codec?.lowercase()) {
    "srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
    "ass", "ssa" -> MimeTypes.TEXT_SSA
    "webvtt", "vtt" -> MimeTypes.TEXT_VTT
    "ttml" -> MimeTypes.APPLICATION_TTML
    else -> null
  }

  override fun setVisible(visible: Boolean) {
    if (disposing) return
    currentVisible = visible
    activity.runOnUiThread {
      if (disposing) return@runOnUiThread
      surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
      // Subtitle views are inside surfaceContainer, so they inherit visibility.
      Log.d(TAG, "setVisible($visible)")
    }
  }

  fun setSubtitleStyle(
    fontSize: Float,
    textColor: String,
    borderSize: Float,
    borderColor: String,
    bgColor: String,
    bgOpacity: Int,
    subtitlePosition: Int = 100,
    bold: Boolean = false,
    italic: Boolean = false
  ) {
    activity.runOnUiThread {
      // 1. Non-ASS subtitles: CaptionStyleCompat on SubtitleView
      val fgColor = Color.parseColor(textColor)
      val bgAlpha = (bgOpacity * 255 / 100)
      val bgColorInt = Color.parseColor(bgColor).let {
        Color.argb(bgAlpha, Color.red(it), Color.green(it), Color.blue(it))
      }
      val edgeColor = Color.parseColor(borderColor)
      val edgeType = if (borderSize > 0) {
        CaptionStyleCompat.EDGE_TYPE_OUTLINE
      } else {
        CaptionStyleCompat.EDGE_TYPE_NONE
      }

      val typefaceStyle = when {
        bold && italic -> Typeface.BOLD_ITALIC
        bold -> Typeface.BOLD
        italic -> Typeface.ITALIC
        else -> Typeface.NORMAL
      }
      val typeface = if (typefaceStyle != Typeface.NORMAL) {
        Typeface.create(Typeface.DEFAULT, typefaceStyle)
      } else {
        null
      }

      val style = CaptionStyleCompat(
        fgColor,
        bgColorInt,
        Color.TRANSPARENT,
        edgeType,
        edgeColor,
        typeface
      )
      subtitleView?.setStyle(style)
      // Font size: MPV sub-font-size is scaled pixels at 720p height
      // Convert to fractional size (0.0-1.0 relative to view height)
      val fraction = fontSize / 720f
      subtitleView?.setFractionalTextSize(fraction)

      // Subtitle position: VTT default cues arrive with explicit line numbers,
      // so app positioning is applied at cue level below.
      val clampedPosition = subtitlePosition.coerceIn(0, 100)
      subtitlePositionPercent = clampedPosition
      subtitleFontSize = fontSize

      // Cue-level positioning handles default VTT/SRT placement, whose line
      // numbers bypass SubtitleView bottom padding. Authored VTT line positions
      // are preserved in applySubtitlePosition().
      if (clampedPosition > 66) {
        val bottomFraction = (100 - clampedPosition) / 100f
        subtitleView?.setBottomPaddingFraction(bottomFraction)
      } else {
        subtitleView?.setBottomPaddingFraction(0f)
      }
      if (lastSubtitleCues.isNotEmpty()) renderSubtitleCues(lastSubtitleCues)

      // 2. ASS subtitles: font scale via libass
      // MPV default sub-font-size is 38
      val defaultSize = 38f
      val scale = fontSize / defaultSize
      try {
        assHandler?.render?.setFontScale(scale)
      } catch (e: Exception) {
        Log.w(TAG, "Failed to set ASS font scale: ${e.message}")
      }

      Log.d(TAG, "setSubtitleStyle: fontSize=$fontSize, textColor=$textColor, borderSize=$borderSize, bgOpacity=$bgOpacity, position=$subtitlePosition, bold=$bold, italic=$italic, assScale=$scale")
    }
  }

  override fun onPipModeChanged(isInPipMode: Boolean) {
    if (disposing) return
    activity.runOnUiThread {
      if (disposing) return@runOnUiThread
      // Force recalculation of surface size based on new container dimensions
      // Use a slight delay to allow the window to resize first
      handler.postDelayed({
        if (disposing) return@postDelayed
        val videoSize = exoPlayer?.videoSize
        if (videoSize != null && videoSize.width > 0 && videoSize.height > 0) {
          updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio)
        }
      }, 100)
    }
  }

  override fun updateFrame() {
    if (disposing) return
    activity.runOnUiThread {
      if (disposing) return@runOnUiThread
      ensureFlutterOverlayOnTop()
      lastVideoSize?.let { videoSize ->
        if (videoSize.width > 0 && videoSize.height > 0) {
          updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio)
        }
      }
    }
  }

  // Audio Focus

  override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false

  override fun abandonAudioFocus() {
    audioFocusManager?.abandonAudioFocus()
  }

  // Frame Rate Matching

  override fun setVideoFrameRate(
    fps: Float,
    videoDurationMs: Long,
    extraDelayMs: Long,
    videoWidth: Int,
    videoHeight: Int,
    onComplete: (switched: Boolean) -> Unit
  ) {
    val mgr = frameRateManager
    if (mgr == null) {
      onComplete(false)
      return
    }
    mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
  }

  override fun clearVideoFrameRate() {
    frameRateManager?.clearVideoFrameRate()
  }

  private fun computeFrameRate(timestamps: LongArray): Float {
    val deltas = (1 until FPS_SAMPLE_COUNT).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 }
    if (deltas.isEmpty()) return -1f
    val medianDelta = deltas.sorted()[deltas.size / 2]
    val rawFps = 1_000_000.0 / medianDelta
    return normalizeFrameRate(rawFps)
  }

  private fun normalizeFrameRate(fps: Double): Float {
    val knownRates = doubleArrayOf(23.976, 24.0, 25.0, 29.97, 30.0, 48.0, 50.0, 59.94, 60.0)
    val nearest = knownRates.minByOrNull { kotlin.math.abs(it - fps) } ?: fps
    return if (kotlin.math.abs(nearest - fps) < 0.5) nearest.toFloat() else fps.toFloat()
  }

  // Stats

  fun getStats(): Map<String, Any?> {
    val player = exoPlayer ?: return emptyMap()
    val videoFormat = player.videoFormat
    val audioFormat = player.audioFormat
    val audioTrackConfig = lastAudioTrackConfig

    // Get decoder info from the format's codecs field and check if hardware accelerated
    val videoDecoderInfo = getVideoDecoderInfo(videoFormat)
    val videoDecoderName = decoderInitName ?: videoDecoderInfo
    val dvPlaybackInfo = buildDvPlaybackInfo(videoFormat, videoDecoderName) ?: lastDvPlaybackInfo

    return mapOf(
      // Video metrics
      "videoCodec" to videoFormat?.codecs,
      "videoMimeType" to videoFormat?.sampleMimeType,
      "videoWidth" to videoFormat?.width,
      "videoHeight" to videoFormat?.height,
      "videoFps" to (videoFormat?.frameRate?.takeIf { it > 0 } ?: detectedFrameRate.takeIf { it > 0 }),
      "videoBitrate" to videoFormat?.bitrate,
      "videoDecoderName" to videoDecoderName,
      "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount,
      "videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount,
      "videoResumeStallRecoveries" to resumeStallRecoveryCount,
      // ASS overlay swap timing (vsync-pinned; late = past the swap-time budget)
      "subSwapCount" to assSubtitleView?.swapCount,
      "subLateSwaps" to assSubtitleView?.lateSwapCount,
      "subMaxLateMs" to assSubtitleView?.maxLateMs,
      // ASS libass render cost (changed renders rewrite the atlas; histogram
      // buckets: ≤10/≤25/≤42/≤84/>84 ms)
      "subRenderCount" to assSubtitleView?.renderCount,
      "subChangedRenders" to assSubtitleView?.changedRenderCount,
      "subOverflows" to assSubtitleView?.overflowCount,
      "subLibassLastMs" to assSubtitleView?.lastLibassMs,
      "subLibassMaxMs" to assSubtitleView?.maxLibassMs,
      "subLibassHist" to assSubtitleView?.libassMsHistogram,
      // ASS render-ahead: hits = served from a pre-rendered frame (GL-only path);
      // minLead ≥ 0 means changed content reached the queue before the video
      // frame's vsync — the frame-perfection signal.
      "subSpecHits" to assSubtitleView?.specHits,
      "subSpecMisses" to assSubtitleView?.specMisses,
      "subSpecSkips" to assSubtitleView?.specSkips,
      "subPrefetches" to assSubtitleView?.prefetchCount,
      "subBlankClears" to assSubtitleView?.blankClearCount,
      "subCoalesced" to assSubtitleView?.coalescedRequestCount,
      "subStaleGeneration" to assSubtitleView?.staleGenerationCount,
      "subSupersededBeforeSwap" to assSubtitleView?.supersededBeforeSwapCount,
      "subStaleBeforeSwap" to assSubtitleView?.staleBeforeSwapCount,
      "subMinLeadMs" to assSubtitleView?.minLeadChangedMs,
      "subPhaseLeadMs" to assSubtitleView?.phaseLeadMs,
      "subLastLeadMs" to assSubtitleView?.lastSwapLeadMs,
      "subLastHeadroomMs" to assSubtitleView?.lastSwapHeadroomMs,
      "subLastSleepMs" to assSubtitleView?.lastScheduledSleepMs,
      // Actual on-screen present time vs the video frame's release target (ground
      // truth for frame-perfection; null/false on emulator + pre-29 devices).
      "subPresentTimingEnabled" to assSubtitleView?.presentTimingEnabled,
      "subPresentSource" to assSubtitleView?.presentSource,
      "subPresentErrMs" to assSubtitleView?.lastPresentErrorMs,
      "subWorstPresentErrMs" to assSubtitleView?.worstPresentErrorMs,
      "subPresentMeasured" to assSubtitleView?.presentMeasuredCount,
      "subPresentInvalid" to assSubtitleView?.presentInvalidCount,
      "subPresentDropped" to assSubtitleView?.presentDroppedCount,
      "subPresentErrHist" to assSubtitleView?.presentErrorHistogram,
      // Color info
      "colorSpace" to videoFormat?.colorInfo?.colorSpace,
      "colorRange" to videoFormat?.colorInfo?.colorRange,
      "colorTransfer" to videoFormat?.colorInfo?.colorTransfer,
      "hdrStaticInfo" to (videoFormat?.colorInfo?.hdrStaticInfo != null),
      // Audio metrics
      "audioCodec" to audioFormat?.codecs,
      "audioMimeType" to audioFormat?.sampleMimeType,
      "audioSampleRate" to audioFormat?.sampleRate,
      "audioChannels" to audioFormat?.channelCount,
      "audioBitrate" to audioFormat?.bitrate,
      "audioDecoderName" to audioDecoderInitName,
      "audioOutputEncoding" to audioTrackConfig?.encoding,
      "audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) },
      "audioOutputChannelConfig" to audioTrackConfig?.channelConfig,
      "audioOutputTunneling" to audioTrackConfig?.tunneling,
      "audioOutputOffload" to audioTrackConfig?.offload,
      "audioOutputBufferSize" to audioTrackConfig?.bufferSize,
      "audioNormalization" to audioNormalizationEnabled,
      "audioNormalizationEffect" to audioNormalization.describe,
      "audioLastSinkError" to lastAudioSinkError,
      "audioRecoveryAttempts" to audioRecoveryAttempts,
      "audioRecoveryLastAction" to lastAudioRecoveryAction,
      "audioRecoveryLastReason" to lastAudioRecoveryReason,
      "audioDirectOutputBlockedMimes" to directAudioOutputBlockedAfterFailure.toList(),
      // Tunneling
      "tunneledPlayback" to currentTunneledPlayback,
      "tunnelingStatus" to getTunnelingStatus(player),
      // Buffer metrics
      "bufferedPositionMs" to player.bufferedPosition,
      "currentPositionMs" to player.currentPosition,
      "totalBufferedDurationMs" to player.totalBufferedDuration,
      // Playback state
      "playbackSpeed" to player.playbackParameters.speed,
      "isPlaying" to player.isPlaying,
      "playbackState" to player.playbackState,
      // DV conversion (query extractor's track output, which is set during extraction)
      *(
        activeDoviMkvWrapper?.doviTrackOutput
          ?: activeDoviMp4Wrapper?.doviTrackOutput
        ).let { dovi ->
        arrayOf(
          "dvConversionActive" to (dovi?.conversionActive == true),
          "dvConversionMode" to dvMode.name,
          "dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"),
          "dvSourceProfile" to dvPlaybackInfo?.sourceProfile,
          "dvPlaybackPath" to dvPlaybackInfo?.path,
          "dvPlaybackReason" to dvPlaybackInfo?.reason,
          "dvStrippedInitNals" to (dovi?.strippedInitNalCount ?: 0L),
          "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L),
          "dvStrippedRpuNals" to (dovi?.strippedRpuNalCount ?: 0L),
          "dvStrippedElNals" to (dovi?.strippedElNalCount ?: 0L),
          "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L),
          "dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L),
          "dvRpuOutputTooSmall" to (dovi?.rpuOutputTooSmallCount ?: 0L),
          "dvAvgRpuConversionUs" to (dovi?.averageRpuConversionTimeUs ?: 0L),
          "dvAvgSampleProcessingUs" to (dovi?.averageSampleProcessingTimeUs ?: 0L)
        )
      }
    )
  }

  private fun getVideoDecoderInfo(videoFormat: androidx.media3.common.Format?): String? {
    if (videoFormat == null) return null
    val mimeType = videoFormat.sampleMimeType ?: return null

    return try {
      MediaCodecQuery.findHardwareDecoder(mimeType, MediaCodecList.ALL_CODECS)?.name ?: "Software"
    } catch (e: Exception) {
      null
    }
  }

  private fun getTunnelingStatus(player: ExoPlayer): String {
    if (currentTunneledPlayback) return "Active"
    if (!tunnelingUserEnabled) return "Disabled by user"
    if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
    if (audioNormalizationEnabled) return "Off (loudness normalization)"
    if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
    if (tunnelingDisabledForDecodedPcm) return "Off (decoded PCM)"
    if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)"
    if (tunnelingDisabledForAudioCodec) return "Off (no HW audio decoder)"
    if (tunnelingDisabledForAssSubtitles) return "Off (ASS subtitles active)"
    return "Off"
  }

  fun triggerFallback() {
    val uri = currentMediaUri ?: return
    val player = exoPlayer
    val pos = player?.currentPosition ?: 0L
    requestFormatFallback(
      mediaGeneration = currentMediaGeneration,
      uri = uri,
      positionMs = pos,
      playWhenReady = player?.playWhenReady ?: true,
      errorMessage = "debug: manual fallback trigger"
    )
  }

  // Cleanup

  fun dispose() {
    if (disposing) return
    disposing = true
    check(Looper.myLooper() == Looper.getMainLooper())
    Log.d(TAG, "Disposing")

    surfaceContainer?.let { container ->
      container.visibility = View.INVISIBLE
      Log.d(TAG, "Hiding surface container during dispose")
    }

    stopFrameWatchdog()
    cancelDecoderHangCheck()
    cancelResumeStallWatchdog()
    stopPositionUpdates()
    handler.removeCallbacksAndMessages(null)
    // releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
    // path, dispose runs after the rate switch has been applied — clearing
    // preferredDisplayModeId here would renegotiate HDMI back to default
    // before MPV's surface comes up. Explicit user-leave still calls
    // clearVideoFrameRate via Dart (video_player_screen.dart).
    frameRateManager?.releasePending()
    frameRateManager = null
    audioFocusManager?.release()
    audioFocusManager = null

    audioNormalization.release()
    pendingAudioRendererBounce = false

    decoderInitName = null
    hasRenderedVideoFrameForMedia = false
    videoDecoderRecoveryConsecutiveAttempts = 0
    videoDecoderRecoveryTotalAttempts = 0
    videoDecoderRecoveryPositionMs = null
    audioDecoderInitName = null
    lastAudioTrackConfig = null
    lastAudioSinkError = null
    audioRecoveryAttempts = 0
    lastAudioRecoveryAction = null
    lastAudioRecoveryReason = null
    directAudioOutputBlockedAfterFailure.clear()
    loggedDirectAudioRecoveryBlocks.clear()
    tunnelingDisabledForAudioCodec = false
    tunnelingDisabledForVideoCodec = false
    tunnelingDisabledForDecodedPcm = false
    tunnelingDisabledForAudioRecovery = false
    tunnelingDisabledForAssSubtitles = false
    currentTunneledPlayback = false
    pendingStartPositionMs = 0L
    pendingPlayWhenReady = null
    currentMediaIsLive = false
    currentVisible = false
    emitSeekable(false, force = true)
    selectedAudioTrackId = null
    selectedSubtitleTrackId = null
    pendingTrackRestore = null
    audioTrackGroupMap.clear()
    subtitleTrackGroupMap.clear()
    exoPlayer?.clearVideoSurface()
    exoPlayer?.removeListener(this)
    exoPlayer?.release()
    exoPlayer = null
    renderersFactory = null
    trackSelector = null
    httpDataSourceFactory = null
    dataSourceFactory = null
    playbackMediaSourceFactory = null
    assHandler?.release()
    assHandler = null

    // Capture locals for deferred cleanup
    val cb = surfaceCallback
    val sv = surfaceView
    val container = surfaceContainer
    val contentView = activity.findViewById<ViewGroup>(android.R.id.content)

    // Synchronous ownership invalidation — stale code can no longer
    // reach surface state through instance fields.
    latencyCalibrator?.stop()
    latencyCalibrator = null
    assSubtitleView?.setPreSwapProbe(null)
    surfaceContainer = null
    videoAspectContainer = null
    surfaceView = null
    subtitleView = null
    bitmapSubtitleView = null
    assSubtitleView = null

    // Remove layout listener synchronously
    overlayLayoutListener?.let { listener ->
      contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener)
    }
    overlayLayoutListener = null

    isInitialized = false

    // Deferred view removal only — uses captured locals.
    // postAtFrontOfQueue as defense-in-depth: orders removal before
    // queued initialize messages.
    Handler(Looper.getMainLooper()).postAtFrontOfQueue {
      sv?.holder?.removeCallback(cb)
      if (container?.parent != null) {
        contentView.removeView(container)
      }
    }

    AndroidRuntimeDiagnostics.clearPlayback(activity)
    Log.d(TAG, "Disposed")
  }
}
