package com.ponzischeme89.memby.ui.player

import android.annotation.SuppressLint
import android.app.AlertDialog
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Typeface
import android.os.Bundle
import android.os.SystemClock
import android.text.format.DateFormat
import android.util.Log
import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import coil.imageLoader
import coil.load
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest
import com.ponzischeme89.memby.data.PlaybackSession
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SUBTITLE_LANGUAGE_AUTO
import com.ponzischeme89.memby.data.SubtitleCandidate
import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.ui.MainActivity
import com.ponzischeme89.memby.ui.EmbyOutageBanner
import com.ponzischeme89.memby.ui.ServiceAlertBanner
import com.ponzischeme89.memby.ui.randomWelcomeQuote
import com.ponzischeme89.memby.ui.theme.MembyTheme
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil

/**
 * Fullscreen Media3 player with native stream-track selection. Press Menu while
 * playing to choose an audio or subtitle track; the subtitle controller button
 * remains available in the regular transport controls too.
 */
class PlayerActivity : ComponentActivity() {

    private var player: ExoPlayer? = null
    private var playerView: PlayerView? = null
    private var progressJob: Job? = null
    private var playbackStarted = false
    private var initialResumePositionMs = 0L
    private var stopReported = false
    private var autoFollowEvaluated = false
    private var itemId: String? = null
    private var mediaSourceId = ""
    private var playSessionId = ""
    private var playMethod = "DirectPlay"
    private var stoppedInBackground = false
    private var availableSubtitles: List<PlayableSubtitle> = emptyList()
    private var encodedSubtitleId: String? = null
    private var remainingView: TextView? = null
    private var finishTimeView: TextView? = null
    private var loadingView: View? = null
    private var loadingTitleView: TextView? = null
    private var loadingHintView: TextView? = null
    private val playbackLoadingQuote: String by lazy {
        randomWelcomeQuote(ServiceLocator.settings.current?.welcomeQuoteStyle)
    }
    private var loadingAnimator: ObjectAnimator? = null
    private var errorView: View? = null
    private var errorTitleView: TextView? = null
    private var errorDetailView: TextView? = null
    private var streamStatusView: TextView? = null
    private var retryJob: Job? = null
    private var stablePlaybackJob: Job? = null
    private var automaticRetryAttempt = 0
    private var renderedFirstFrame = false
    private var requestStartedAtMs = 0L
    private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)

    /**
     * Set when the launcher handed over an item rather than a stream, and cleared the
     * moment one is resolved. While it is non-null the player has no media item at all,
     * which is why the error overlay's Retry has to re-run the resolution rather than
     * re-prepare nothing.
     */
    private var pendingRequest: PlaybackRequest? = null
    private var pendingResolveJob: Job? = null
    private var serviceAlertsMounted = false

    // Open trace spans, or -1 for "not open". Held so they can be closed on destroy: a
    // launch that never reaches a frame must not leave a slice running to the end of the
    // capture, which would show up as one enormous launch rather than a failed one.
    private var launchTraceCookie = NO_TRACE
    private var firstFrameTraceCookie = NO_TRACE
    private var logoUrl: String? = null
    private var playbackTitle = ""
    private var pauseOverview = ""
    private var prerollEpisodeCode = ""
    private var prerollRuntimeMs = 0L
    private var prerollDurationMs = DEFAULT_PREROLL_DURATION_MS
    private var pausePosterUrl: String? = null
    private var pauseOverlay: View? = null
    private var nowPlayingGroup: View? = null
    private var playbackIdentityView: View? = null
    private var playbackIdentityHideJob: Job? = null
    private var playbackIdentityShown = false
    private var bufferingStartedAtMs: Long? = null
    private var totalBufferingMs = 0L
    private var bufferingCount = 0
    private var subtitleAutoSelectionAttempted = false
    /**
     * Whether subtitles are wanted at all, seeded from the viewer's synced settings and
     * updated the moment they choose something in the overlay. Null only until the intent
     * is read.
     */
    private var subtitlePreference: Boolean? = null
    /**
     * The track the gateway picked for this stream from those same settings. It is the
     * server's answer because the server is what enumerated the tracks; blank means it had
     * no opinion, and the television falls back to its own flag-driven pick.
     */
    private var serverSubtitleId = ""
    private var currentVideoSize = VideoSize.UNKNOWN
    /**
     * Whether this backend can fetch a subtitle the title does not have. The gateway
     * answers it on the playback response; it stays false everywhere else, so a direct
     * install and an older gateway simply never see the section.
     */
    private var subtitleDownloadAvailable = false
    private var subtitleSearchJob: Job? = null
    /**
     * Whether a search or a download is in flight, which is what disables the rows.
     *
     * Deliberately a flag rather than `subtitleSearchJob?.isActive`. `lifecycleScope` uses
     * the immediate main dispatcher, so a coroutine body runs synchronously up to its first
     * suspension — before `subtitleSearchJob =` has been assigned. Reading the job would
     * therefore see the *previous* one on the very redraw that is meant to show the rows
     * disabled, and the search row would stay pressable while its own search ran.
     */
    private var subtitleRequestInFlight = false
    /**
     * The download section's state, which is these two fields and nothing else — the rows
     * are derived from the candidates on every draw, so the drop-up cannot get out of step
     * with what was found. It survives the overlay being closed on purpose: a provider
     * query takes seconds, and somebody who presses Back while it runs should come back to
     * the answer rather than to nothing.
     */
    private var subtitleDownloadStatus = ""
    private var subtitleCandidates: List<GatewaySubtitleCandidate> = emptyList()
    /**
     * Whether the drop-up has switched to being about fetching a subtitle. Back steps out
     * of it before it closes the overlay, one press per level, the same contract the search
     * screen's Back has.
     */
    private var subtitleDownloadExpanded = false
    /** Shown once a manually selected subtitle survives a media-item reload. */
    private var pendingSubtitleConfirmation: String? = null
    private var subtitleOverlay: View? = null
    private var castOverlay: View? = null
    private var castJob: Job? = null
    private var castPeople: List<EmbyPerson> = emptyList()
    private var castLoaded = false
    private var prerollView: View? = null
    private var prerollTimerJob: Job? = null
    private var prerollScheduleJob: Job? = null
    private var prerollActive = true
    private var prerollHandOffStarted = false
    private var prerollMinimumElapsed = false
    /**
     * Whether the service-alert bar is held back. Starts true: the preroll and the
     * loading overlay both come before the first frame, and an alert shown over either
     * would be marked seen by a viewer who never saw it.
     */
    private val alertsSuppressed = mutableStateOf(true)
    private var fullscreenPlayerParent: ViewGroup? = null
    private var fullscreenPlayerIndex = -1
    private var fullscreenPlayerLayoutParams: ViewGroup.LayoutParams? = null

    // "Next up" state. [nextEpisode] is prefetched as soon as playback settles so the
    // banner can appear — and the next episode start — without waiting on the network.
    private var nextEpisode: NextEpisode? = null
    private var returningHomeAfterCompletion = false
    private var nextUpJob: Job? = null
    private var nextUpBanner: View? = null
    private var nextUpCountdown: TextView? = null
    private var nextUpDismissed = false
    private var advancing = false

    // Skipping the opening titles. [skipIntroSegment] is where Emby says they are, fetched
    // once playback has settled; the rest is what this episode's viewer has done about it.
    private var skipIntroAvailable = false
    private var skipIntroView: View? = null
    private var skipIntroButton: View? = null
    private var skipIntroLabel: TextView? = null
    private var skipIntroCountdown: SkipIntroCountdownView? = null
    private var skipIntroSegment: IntroSegment? = null
    private var skipIntroJob: Job? = null
    private var skipIntroLookupJob: Job? = null
    /**
     * This episode's titles have been dealt with — skipped by hand, or skipped for the
     * viewer in automatic mode. Deliberately never reset while the same episode is
     * playing, unlike [skipIntroDismissed]: in automatic mode a viewer who rewinds to the
     * start of an episode has to be able to sit through the opening they just went back
     * for, and re-arming would drag them forward again the moment they got there.
     */
    private var skipIntroTaken = false
    private var skipIntroDismissed = false

    // The closing credits, moved aside and sped up. [creditsStartMs] is where Emby says they
    // begin, read from the same chapter lookup the intro markers come out of, so this whole
    // feature costs no request of its own.
    private var endCreditsAvailable = false
    private var creditsView: View? = null
    private var creditsCountdown: TextView? = null
    private var creditsSpeedChip: TextView? = null
    private var creditsStartMs: Long? = null
    private var creditsActive = false
    /**
     * The viewer asked to watch the credits, so nothing offers again for this episode.
     *
     * Never re-armed within an episode, the way [skipIntroTaken] is not: somebody who pressed
     * "Watch credits" and then seeked back into the roll wants the roll, and re-offering
     * would shrink their picture again the moment they got there.
     */
    private var creditsDismissed = false
    private var creditsSpeedJob: Job? = null
    /**
     * The fastest this stream has been allowed to run. It only ever falls — see
     * [creditsCeilingAfterStall]. Reset per episode, because the next file may be a
     * different bitrate entirely and one that stalled says nothing about the next.
     */
    private var creditsSpeedCeiling = CREDITS_TARGET_SPEED
    private var creditsEnteredAtMs = 0L

    // The ten-minute lower third. Shown once per item — a viewer who has been told is
    // told; re-announcing it every time they seek would be nagging, not informing.
    private var timeRemainingCue: View? = null
    private var timeRemainingLabel: TextView? = null
    private var timeRemainingValue: TextView? = null
    private var timeRemainingHideJob: Job? = null
    private var timeRemainingShown = false
    private var playbackStartCueJob: Job? = null

    // Skipping. The preview is what the OSD is drawing and what a commit will seek to; it
    // is deliberately not the playhead, so a burst of presses is one seek. See SeekControls.
    private var seekIndicator: View? = null
    private var seekGlyph: ImageView? = null
    private var seekAmountView: TextView? = null
    private var seekPositionView: TextView? = null
    private var seekPreview: SeekPreview? = null
    private var seekCommitJob: Job? = null
    private var seekHideJob: Job? = null

    /**
     * The frame the skip will land on, drawn beside the words in the same chip. It is a
     * decoration on an indicator that already works without it, so nothing here is on the
     * path of a press: see [TrickplayPreview].
     */
    private var trickplayAvailable = false

    /**
     * The same frames, drawn above the time bar while the viewer scrubs it.
     *
     * Skipping with the transport hidden and scrubbing with it up are the two ways to move
     * through a film, and only one of them used to show where it was going — which read as
     * the previews having stopped working the moment somebody pressed a button to look at
     * the controls. [scrubAnchor] is the bar the strip rides above; both are owned by the
     * controller layout, so they go away with it and need no hiding of their own.
     */
    private var scrubPreview: ImageView? = null
    private var scrubAnchor: View? = null
    private var scrubPositionMs = 0L
    private val trickplayPreview by lazy {
        TrickplayPreview(
            scope = lifecycleScope,
            loadTrack = { ServiceLocator.repository.trickplay(it) },
            loadFrame = { track, frame -> ServiceLocator.repository.trickplayFrame(track, frame) },
        )
    }

    /**
     * A seek is landing, so the buffering it causes belongs to the skip and not to the
     * film. While this is set the loading overlay is withheld: the viewer asked to move
     * and the OSD saying "+30s · 1:12:40" is the answer to that; covering their film with
     * "Opening Memby…" reads as a failure, and — because [seekControlsActive] refuses to
     * act while the overlay is up — it also swallowed the next press of the same key.
     * Cleared the moment the decoder catches up, and by [seekLoadingFallbackJob] if it
     * does not, since a frozen frame with nothing said about it is worse than the overlay.
     */
    private var seekBuffering = false
    private var seekLoadingFallbackJob: Job? = null
    private var playbackStartCueShown = false
    private var seasonFinaleCue: View? = null
    private var seasonFinaleValue: TextView? = null
    private var seasonFinaleInfo: GatewaySeasonFinale? = null
    private var seasonFinaleJob: Job? = null
    private var seasonFinaleHideJob: Job? = null
    private var seasonFinaleShown = false

    @UnstableApi
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

        requestStartedAtMs = intent.getLongExtra(
            EXTRA_REQUEST_STARTED_AT_MS,
            SystemClock.elapsedRealtime(),
        )
        trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime)
        trace.mark(PlaybackTrace.ACTIVITY_CREATED)
        beginLaunchTrace()

        // A launch arrives one of two ways: with a stream already negotiated (the launcher
        // had a fresh prefetch in hand), or with the request that resolves one. The second
        // form exists so this activity, its layout and its decoder start while the server
        // is still being asked — see [PlaybackRequest].
        val url = intent.getStringExtra(EXTRA_URL)?.takeIf(String::isNotBlank)
        val request = decodeRequest(intent.getStringExtra(EXTRA_PLAYBACK_REQUEST))
        pendingRequest = request.takeIf { url == null }
        if (url == null && request == null) {
            finish()
            return
        }

        itemId = intent.getStringExtra(EXTRA_ITEM_ID)
        mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty()
        playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty()
        playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD) ?: "DirectPlay"
        val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
        initialResumePositionMs = resumePositionMs.coerceAtLeast(0L)
        val prerollEnabled = intent.getBooleanExtra(EXTRA_PREROLL_ENABLED, true)
        prerollDurationMs = intent.getLongExtra(
            EXTRA_PREROLL_DURATION_MS,
            DEFAULT_PREROLL_DURATION_MS,
        ).coerceIn(MIN_PREROLL_DURATION_MS, MAX_PREROLL_DURATION_MS)
        val showPreroll = shouldShowPreroll(resumePositionMs, prerollEnabled)
        val subtitles = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
        availableSubtitles = subtitles
        // Falling back to this TV's copy of the setting rather than to `true` matters for
        // the request path, where the intent carries no answer yet: somebody who turned
        // subtitles off must not get them back for the seconds before one is resolved.
        subtitlePreference = intent.getBooleanExtra(
            EXTRA_SUBTITLES_ENABLED,
            ServiceLocator.settings.current?.subtitlesEnabled ?: true,
        )
        serverSubtitleId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty()
        // Default false, not true: a missing extra must never conjure a section whose
        // only row leads to a request the backend cannot answer.
        subtitleDownloadAvailable = intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false)
        // Same rule, and the same default, for the three that decide whether the previews,
        // the skip button and the credits pane are offered at all. On the request form of
        // the launch these are corrected by adoptPlayable once the server settles; on this
        // form the intent is the only thing that will ever say.
        trickplayAvailable = intent.getBooleanExtra(EXTRA_TRICKPLAY, false)
        skipIntroAvailable = intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
        endCreditsAvailable = intent.getBooleanExtra(EXTRA_END_CREDITS, false)
        Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")

        setContentView(R.layout.activity_player)
        val view = findViewById<PlayerView>(R.id.player_view).apply {
            useController = false
            setShowSubtitleButton(true)
            controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
        }
        applySubtitleAppearance(view)
        playerView = view
        view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
            showSubtitleOverlay()
        }
        view.findViewById<View>(R.id.player_cast)?.setOnClickListener { showCastOverlay() }
        view.findViewById<View>(R.id.player_hide_controls)?.setOnClickListener { view.hideController() }
        view.findViewById<View>(R.id.player_exit)?.setOnClickListener { finish() }
        view.findViewById<View>(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener {
            showTrackMenu()
        }
        bindScrubPreview(view)
        applyPictureMode()
        loadingView = findViewById(R.id.playback_loading)
        loadingTitleView = findViewById(R.id.playback_loading_title)
        loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also {
            // Set this as soon as the layout is mounted so the XML fallback never flashes
            // before the first buffering callback.
            it.text = playbackLoadingQuote
        }
        loadingAnimator = ObjectAnimator.ofFloat(
            findViewById<ImageView>(R.id.playback_loading_logo),
            View.ROTATION,
            0f,
            360f,
        ).apply {
            duration = 850L
            repeatCount = ValueAnimator.INFINITE
            interpolator = LinearInterpolator()
            start()
        }
        // Both live in memby_player_controls.xml, which PlayerView inflates for itself via
        // app:controller_layout_id — so they hang off the PlayerView, not the activity's layout.
        remainingView = view.findViewById(R.id.player_remaining)
        finishTimeView = view.findViewById(R.id.player_finish_time)
        streamStatusView = view.findViewById(R.id.player_stream_status)
        logoUrl = intent.getStringExtra(EXTRA_LOGO_URL)
        playbackTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty()
        pauseOverview = intent.getStringExtra(EXTRA_OVERVIEW).orEmpty()
        prerollEpisodeCode = intent.getStringExtra(EXTRA_EPISODE_CODE).orEmpty()
        prerollRuntimeMs = intent.getLongExtra(EXTRA_RUNTIME_MS, 0L).coerceAtLeast(0L)
        pausePosterUrl = intent.getStringExtra(EXTRA_POSTER_URL)
        if (showPreroll) startPreroll() else startWithoutPreroll()

        // --- The critical path ------------------------------------------------------
        // Everything above this point is what the player needs in order to open the
        // stream; everything below it is decoration. The order matters: building a
        // decoder and opening a file part-way through are the two slowest things a
        // launch does, and none of the overlays, artwork or cast lookups they used to
        // queue behind can be seen until the first frame lands anyway. Nothing here can
        // race the listener either — media3 posts its callbacks to this thread, so the
        // first one cannot arrive until onCreate has returned.
        player = PlayerEngine.create(this)
            .also { playback ->
                view.player = playback
                trace.mark(PlaybackTrace.PLAYER_BUILT)
                playback.addListener(object : Player.Listener {
                    override fun onPlaybackStateChanged(playbackState: Int) {
                        recordPlaybackState(playbackState)
                        when (playbackState) {
                            Player.STATE_BUFFERING -> {
                                // A stall at double speed is the stream failing to keep up
                                // with it, so the ceiling falls before the overlay goes up.
                                stepDownCreditsSpeed()
                                if (!prerollActive && !seekBuffering) showPlaybackLoading()
                            }
                            Player.STATE_READY -> {
                                trace.mark(PlaybackTrace.READY)
                                endSeekBuffering()
                                hidePlaybackError()
                                pendingSubtitleConfirmation?.let { label ->
                                    pendingSubtitleConfirmation = null
                                    showSubtitleConfirmation(label)
                                }
                                if (prerollActive) bindPrerollNow(playback.duration)
                                if (!prerollActive && renderedFirstFrame) {
                                    hidePlaybackLoading()
                                }
                                updatePauseOverlay(playback)
                            }
                            Player.STATE_ENDED -> handlePlaybackEnded()
                            Player.STATE_IDLE -> Unit
                        }
                    }

                    override fun onIsPlayingChanged(isPlaying: Boolean) {
                        if (playbackStarted) {
                            reportProgress(
                                playback.currentPosition,
                                isPaused = !isPlaying,
                                eventName = if (isPlaying) "Unpause" else "Pause",
                            )
                        }
                        if (isPlaying) scheduleRetryBudgetReset() else stablePlaybackJob?.cancel()
                        updatePauseOverlay(playback)
                    }

                    override fun onEvents(player: Player, events: Player.Events) {
                        updatePlaybackTiming(player)
                        // A skip that landed inside what was already buffered never
                        // reports a state change or a new first frame, so this is the
                        // only place that hears about it — without it the OSD it raised
                        // would sit on the picture for the rest of the film.
                        if (player.playbackState == Player.STATE_READY) endSeekBuffering()
                        if (playbackStarted && events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
                            reportProgress(player.currentPosition, !player.isPlaying, "TimeUpdate")
                        }
                    }

                    override fun onTracksChanged(tracks: Tracks) {
                        updateStreamStatus(tracks)
                        ensureSubtitleSelected(tracks)
                    }

                    override fun onVideoSizeChanged(videoSize: VideoSize) {
                        currentVideoSize = videoSize
                        applyPictureMode()
                    }

                    override fun onPlayerError(error: PlaybackException) {
                        handlePlaybackError(error)
                    }

                    override fun onRenderedFirstFrame() {
                        renderedFirstFrame = true
                        endSeekBuffering()
                        hidePlaybackLoading()
                        if (!playbackStarted) {
                            startPlaybackSession(playback)
                        }
                        if (prerollActive) {
                            startPrerollCountdown()
                        }
                        endFirstFrameTrace()
                        endLaunchTrace()
                        val firstFrameMs = trace.mark(PlaybackTrace.FIRST_FRAME)
                        Log.i(
                            PLAYBACK_LOG_TAG,
                            "event=first_frame item=${itemId.orEmpty()} totalMs=$firstFrameMs " +
                                "resumeMs=$initialResumePositionMs ${trace.summary()}",
                        )
                    }
                })
            }

        // Fresh playback waits in the pre-roll frame. A resumed item is already in its
        // full-screen parent and should begin as soon as it is ready.
        if (url != null) {
            trace.mark(PlaybackTrace.STREAM_RESOLVED)
            startMedia(url, subtitles, resumePositionMs, playWhenReady = !showPreroll)
        } else {
            resolvePendingStream(requireNotNull(request), playWhenReady = !showPreroll)
        }

        // --- Decoration -------------------------------------------------------------
        bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
        bindPauseOverlay(view)
        setUpPlaybackIdentity(title = playbackTitle)
        setUpSubtitleOverlay()
        setUpCastOverlay()
        setUpNextUpBanner()
        setUpSkipIntro()
        setUpEndCredits()
        setUpTimeRemainingCue()
        setUpSeasonFinaleCue()
        setUpPlaybackError()

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                ServiceLocator.maintenance.notice.filterNotNull().collect {
                    interruptForMaintenance()
                }
            }
        }
    }

    /** Hands the player a stream. The one place `prepare()` is called for a new item. */
    private fun startMedia(
        url: String,
        subtitles: List<PlayableSubtitle>,
        positionMs: Long,
        playWhenReady: Boolean,
    ) {
        val playback = player ?: return
        playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
        playback.playWhenReady = playWhenReady
        playback.prepare()
        trace.mark(PlaybackTrace.PREPARED)
        endFirstFrameTrace()
        firstFrameTraceCookie = PlaybackTraceSections.nextCookie()
        PlaybackTraceSections.begin(PlaybackTraceSections.FIRST_FRAME, firstFrameTraceCookie)
    }

    private fun beginLaunchTrace() {
        endLaunchTrace()
        launchTraceCookie = PlaybackTraceSections.nextCookie()
        PlaybackTraceSections.begin(PlaybackTraceSections.LAUNCH, launchTraceCookie)
    }

    private fun endLaunchTrace() {
        if (launchTraceCookie == NO_TRACE) return
        PlaybackTraceSections.end(PlaybackTraceSections.LAUNCH, launchTraceCookie)
        launchTraceCookie = NO_TRACE
    }

    private fun endFirstFrameTrace() {
        if (firstFrameTraceCookie == NO_TRACE) return
        PlaybackTraceSections.end(PlaybackTraceSections.FIRST_FRAME, firstFrameTraceCookie)
        firstFrameTraceCookie = NO_TRACE
    }

    /**
     * Resolves the stream for a launch that arrived as a request rather than a URL, and
     * starts it. The wait is the same one the launcher used to sit through before opening
     * this activity at all; the difference is that the activity, its layout and its
     * decoder are now being built during it rather than after it.
     */
    private fun resolvePendingStream(request: PlaybackRequest, playWhenReady: Boolean) {
        showPlaybackLoading()
        pendingResolveJob?.cancel()
        pendingResolveJob = lifecycleScope.launch {
            runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) }
                .onSuccess { playable ->
                    if (isFinishing || isDestroyed) return@onSuccess
                    pendingRequest = null
                    adoptPlayable(playable)
                    trace.mark(PlaybackTrace.STREAM_RESOLVED)
                    startMedia(
                        url = playable.url,
                        subtitles = playable.subtitles,
                        positionMs = playable.resumePositionMs,
                        playWhenReady = playWhenReady,
                    )
                }
                .onFailure { error ->
                    if (isFinishing || isDestroyed) return@onFailure
                    Log.e(
                        PLAYBACK_LOG_TAG,
                        "event=stream_resolve_failed item=${request.itemId}",
                        error,
                    )
                    showPlaybackError(
                        PlaybackFailure(
                            title = getString(R.string.playback_server_unreachable),
                            detail = getString(R.string.playback_server_unreachable_detail),
                            canAutoRetry = false,
                        ),
                    )
                }
        }
    }

    /**
     * Takes on the identity the server settled: for a series the launcher only knew the
     * show, and it is the resolution that says which episode. The loading screen is already
     * on the viewer's television by now, so anything the hints got wrong is corrected
     * before the first frame rather than after it.
     */
    private fun adoptPlayable(playable: Playable) {
        itemId = playable.itemId
        mediaSourceId = playable.mediaSourceId
        playSessionId = playable.playSessionId
        playMethod = playable.playMethod
        availableSubtitles = playable.subtitles
        subtitlePreference = playable.subtitlesEnabled
        serverSubtitleId = playable.selectedSubtitleId
        subtitleDownloadAvailable = playable.subtitleDownloadAvailable
        trickplayAvailable = playable.trickplayAvailable
        skipIntroAvailable = playable.skipIntroAvailable
        endCreditsAvailable = playable.endCreditsAvailable
        subtitleAutoSelectionAttempted = false
        initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
        playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
        playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it }
        playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it }
        playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it }
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=subtitle_configs item=${playable.itemId} " +
                "count=${playable.subtitles.size} source=launch",
        )
        if (playable.title.isBlank() || playable.title == playbackTitle) return
        playbackTitle = playable.title
        bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
        setUpPlaybackIdentity(title = playbackTitle)
        pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
    }

    private fun startPreroll() {
        val overlay = findViewById<View>(R.id.player_preroll)
        prerollView = overlay
        prerollActive = true
        prerollHandOffStarted = false
        prerollMinimumElapsed = false
        overlay.visibility = View.VISIBLE
        loadingView?.visibility = View.GONE
        playerView?.apply {
            useController = false
            hideController()
        }
        enterPrerollVideoFrame()
        bindPrerollNow()
        bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
        prerollScheduleJob = lifecycleScope.launch {
            val schedule = ServiceLocator.repository.prerollSchedule()
            if (prerollActive) bindPrerollSchedule(schedule, loading = false)
        }
        findViewById<PrerollCountdownView>(R.id.player_preroll_countdown).setCountdown(
            seconds = ceil(prerollDurationMs / 1_000.0).toInt(),
            progress = 1f,
            description = resources.getQuantityString(
                R.plurals.player_preroll_countdown,
                ceil(prerollDurationMs / 1_000.0).toInt(),
                ceil(prerollDurationMs / 1_000.0).toInt(),
            ),
        )
    }

    /** Resume is a continuation, not a new programme start, so it bypasses the pre-roll. */
    private fun startWithoutPreroll() {
        prerollView = findViewById<View>(R.id.player_preroll).also {
            it.visibility = View.GONE
        }
        prerollActive = false
        prerollHandOffStarted = false
        prerollMinimumElapsed = false
        playerView?.apply {
            useController = true
            hideController()
        }
        showPlaybackLoading()
    }

    private fun startPrerollCountdown() {
        if (prerollTimerJob != null) return
        prerollTimerJob = lifecycleScope.launch {
            val countdown = findViewById<PrerollCountdownView>(R.id.player_preroll_countdown)
            val durationMs = prerollDurationMs
            var watchedMs = 0L
            var lastTickMs = SystemClock.elapsedRealtime()
            while (prerollActive && watchedMs < durationMs) {
                val remainingMs = durationMs - watchedMs
                val seconds = ceil(remainingMs / 1_000.0).toInt().coerceAtLeast(1)
                val description = resources.getQuantityString(
                    R.plurals.player_preroll_countdown,
                    seconds,
                    seconds,
                )
                countdown.setCountdown(
                    seconds = seconds,
                    progress = remainingMs.toFloat() / durationMs,
                    description = description,
                )
                delay(PREROLL_TIMER_TICK_MS)
                val nowMs = SystemClock.elapsedRealtime()
                if (prerollCountdownAdvances(
                        lifecycleStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
                        playbackReady = player?.playbackState == Player.STATE_READY,
                    )
                ) {
                    watchedMs = (watchedMs + nowMs - lastTickMs).coerceAtMost(durationMs)
                }
                lastTickMs = nowMs
            }
            if (!prerollActive) return@launch
            prerollMinimumElapsed = true
            countdown.setCountdown(
                seconds = 0,
                progress = 0f,
                description = getString(R.string.player_preroll_starting),
            )
            beginContentWhenReady()
        }
    }

    private fun enterPrerollVideoFrame() {
        val view = playerView ?: return
        val currentParent = view.parent as? ViewGroup ?: return
        if (fullscreenPlayerParent != null) return
        val host = findViewById<FrameLayout>(R.id.player_preroll_video_host)
        fullscreenPlayerParent = currentParent
        fullscreenPlayerIndex = currentParent.indexOfChild(view)
        fullscreenPlayerLayoutParams = view.layoutParams
        currentParent.removeView(view)
        host.addView(
            view,
            0,
            FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
            ),
        )
    }

    private fun restoreFullscreenPlayer() {
        val view = playerView ?: return
        val parent = fullscreenPlayerParent ?: return
        (view.parent as? ViewGroup)?.removeView(view)
        parent.addView(
            view,
            fullscreenPlayerIndex.coerceIn(0, parent.childCount),
            fullscreenPlayerLayoutParams,
        )
        fullscreenPlayerParent = null
        fullscreenPlayerIndex = -1
        fullscreenPlayerLayoutParams = null
    }

    private fun beginContentWhenReady() {
        if (prerollHandOffStarted) return
        if (!prerollCanHandOff(
                active = prerollActive,
                minimumElapsed = prerollMinimumElapsed,
                lifecycleStarted = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
            )
        ) return
        val playback = player ?: return
        prerollHandOffStarted = true
        prerollActive = false
        animatePrerollToFullscreen(playback)
    }

    private fun animatePrerollToFullscreen(playback: Player) {
        val overlay = prerollView ?: return finishPrerollHandOff(playback)
        val host = findViewById<View>(R.id.player_preroll_video_host)
        if (host.width == 0 || host.height == 0 || overlay.width == 0 || overlay.height == 0) {
            finishPrerollHandOff(playback)
            return
        }
        val overlayLocation = IntArray(2).also(overlay::getLocationOnScreen)
        val hostLocation = IntArray(2).also(host::getLocationOnScreen)
        val targetScale = maxOf(
            overlay.width.toFloat() / host.width,
            overlay.height.toFloat() / host.height,
        )
        val targetTranslationX =
            overlayLocation[0] + overlay.width / 2f - (hostLocation[0] + host.width / 2f)
        val targetTranslationY =
            overlayLocation[1] + overlay.height / 2f - (hostLocation[1] + host.height / 2f)

        findViewById<View>(R.id.player_preroll_schedule_panel).animate()
            .alpha(0f)
            .setDuration(PREROLL_FADE_MS)
            .start()
        findViewById<View>(R.id.player_preroll_calendar).animate()
            .alpha(0f)
            .setDuration(PREROLL_FADE_MS)
            .start()
        findViewById<View>(R.id.player_preroll_brand).animate()
            .alpha(0f)
            .setDuration(PREROLL_FADE_MS)
            .start()
        findViewById<View>(R.id.player_preroll_countdown_container).animate()
            .alpha(0f)
            .setDuration(PREROLL_FADE_MS)
            .start()
        host.translationZ = dp(24).toFloat()
        host.animate()
            .scaleX(targetScale)
            .scaleY(targetScale)
            .translationX(targetTranslationX)
            .translationY(targetTranslationY)
            .setDuration(PREROLL_TRANSITION_MS)
            .setInterpolator(DecelerateInterpolator())
            .withEndAction { finishPrerollHandOff(playback) }
            .start()
    }

    private fun finishPrerollHandOff(playback: Player) {
        restoreFullscreenPlayer()
        prerollView?.visibility = View.GONE
        playerView?.apply {
            useController = true
            hideController()
        }
        startPlaybackSession(playback)
        showPlaybackIdentity()
        playback.playWhenReady = true
        if (playback.playbackState != Player.STATE_READY) {
            showPlaybackLoading()
        } else {
            updatePlaybackTiming(playback)
        }
    }

    private fun startPlaybackSession(playback: Player) {
        if (playbackStarted) return
        playbackStarted = true
        if (!prerollActive) showPlaybackIdentity()
        // Deliberately here rather than in onCreate. Nothing needs the cast until the
        // viewer opens the overlay, and on a series the launcher does not yet know which
        // episode to ask about — but the request used to go out during onCreate, competing
        // for the connection pool with the stream nobody has seen a frame of yet.
        loadCast()
        // Same reasoning as the cast, and the same moment: the seek previews are wanted by
        // the first press of Left or Right, which cannot come before there is a picture, so
        // reading their layout during onCreate would only have competed with the decoder
        // for the connection pool.
        itemId?.let { trickplayPreview.prepare(it, trickplayAvailable) }
        // And the same moment again, for the same reason: the opening titles are minutes
        // away and the markers that describe them are worth nothing until there is a
        // picture to skip forward in.
        loadIntroSegment()
        // Free: the repository has cached one reading of the chapter list, and the credits
        // marker is the other half of what the line above just fetched.
        loadCreditsStart()
        startSkipIntroWatch()
        reportStarted(playback.currentPosition)
        startProgressReporting()
        startPlaybackStartCueWatch()
        prefetchSeasonFinale()
        prefetchNextEpisode()
        startNextUpWatch()
    }

    private fun bindPrerollSchedule(schedule: GatewayPrerollSchedule, loading: Boolean) {
        val calendar = findViewById<GridLayout>(R.id.player_preroll_calendar)
        calendar.removeAllViews()
        val entries = (
            schedule.today.map { "TODAY" to it } +
                schedule.thisWeek.map { "THIS WEEK" to it }
            ).take(PREROLL_CALENDAR_SIZE)
        if (entries.isEmpty()) {
            calendar.addView(
                prerollMessage(
                    if (loading) "Checking what’s coming up…"
                    else "Nothing else is scheduled today or this week.",
                ),
                GridLayout.LayoutParams().apply {
                    width = 0
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                    columnSpec = GridLayout.spec(0, PREROLL_CALENDAR_COLUMNS, 1f)
                    rowSpec = GridLayout.spec(0)
                },
            )
            return
        }
        entries.forEachIndexed { index, (label, entry) ->
            val cardLabel = listOf(
                label.takeIf { label == "TODAY" },
                entry.schedule.takeIf(String::isNotBlank),
            ).filterNotNull().joinToString("  ·  ").uppercase()
            calendar.addView(
                prerollEntry(entry, cardLabel),
                GridLayout.LayoutParams().apply {
                    width = 0
                    height = dp(PREROLL_CALENDAR_CARD_HEIGHT_DP)
                    columnSpec = GridLayout.spec(index % PREROLL_CALENDAR_COLUMNS, 1f)
                    rowSpec = GridLayout.spec(index / PREROLL_CALENDAR_COLUMNS)
                    setMargins(dp(4), dp(3), dp(4), dp(3))
                },
            )
        }
    }

    private fun bindPrerollNow(playerDurationMs: Long = 0L) {
        findViewById<TextView>(R.id.player_preroll_now_title).text =
            playbackTitle.ifBlank { "Starting playback" }
        val runtime = formatPrerollRuntime(
            playerDurationMs.takeIf { it != C.TIME_UNSET && it > 0L } ?: prerollRuntimeMs,
        )
        findViewById<TextView>(R.id.player_preroll_now_metadata).text = listOfNotNull(
            if (prerollEpisodeCode.isNotBlank()) "Episode" else null,
            prerollEpisodeCode.takeIf(String::isNotBlank),
            runtime,
        ).joinToString("  ·  ")
        findViewById<TextView>(R.id.player_preroll_now_overview).text =
            pauseOverview.ifBlank { "Memby is preparing this episode for playback." }
    }

    private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
        LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
            findViewById<TextView>(R.id.player_preroll_card_label).text = label
            findViewById<TextView>(R.id.player_preroll_card_title).text = entry.series
            findViewById<TextView>(R.id.player_preroll_card_detail).text = listOfNotNull(
                entry.episodeCode.takeIf(String::isNotBlank),
                entry.episode.takeIf(String::isNotBlank),
                entry.availability.takeIf(String::isNotBlank),
            ).joinToString("  ·  ")
            ServiceLocator.repository.prerollArtworkUrl(
                entry.itemId,
                entry.imageType,
                640,
            )?.let { artwork ->
                findViewById<ImageView>(R.id.player_preroll_card_artwork).load(artwork) {
                    crossfade(true)
                }
            }
        }

    private fun prerollMessage(message: String): TextView =
        TextView(this).apply {
            text = message
            setTextColor(Color.rgb(142, 151, 157))
            textSize = 14f
            setPadding(0, dp(7), 0, dp(7))
        }

    private fun showPlaybackLoading(
        title: String = getString(R.string.playback_loading),
        hint: String = playbackLoadingQuote,
    ) {
        if (prerollActive) return
        // Every caller of this is a deliberate decision that the viewer should be told
        // something — including the skip's own fallback. Whatever the seek was holding
        // back, it is no longer holding it back.
        seekBuffering = false
        seekLoadingFallbackJob?.cancel()
        seekLoadingFallbackJob = null
        loadingTitleView?.text = title
        loadingHintView?.text = hint
        loadingView?.visibility = View.VISIBLE
        if (loadingAnimator?.isStarted != true) loadingAnimator?.start()
        alertsSuppressed.value = true
    }

    private fun hidePlaybackLoading() {
        loadingView?.visibility = View.GONE
        loadingAnimator?.cancel()
        // Reached only once the first frame is on screen and the preroll is over, so
        // this is exactly "the film is playing" — the only state in which a notice
        // about the library is worth interrupting anyone for.
        alertsSuppressed.value = errorView?.visibility == View.VISIBLE
        // Which is also the first moment the bar could be shown, so this is the first
        // moment worth paying for a Compose composition. Starting one during onCreate
        // put the whole of Compose's first-frame cost on the main thread at exactly the
        // point the decoder wanted it, to render something guaranteed to be suppressed.
        mountServiceAlerts()
    }

    private fun setUpPlaybackError() {
        errorView = findViewById<View>(R.id.playback_error).also { overlay ->
            errorTitleView = overlay.findViewById(R.id.playback_error_title)
            errorDetailView = overlay.findViewById(R.id.playback_error_detail)
            overlay.findViewById<View>(R.id.playback_error_retry).setOnClickListener {
                automaticRetryAttempt = 0
                retryPlayback(refreshSource = true)
            }
            overlay.findViewById<View>(R.id.playback_error_exit).setOnClickListener {
                finish()
            }
        }
    }

    private fun handlePlaybackError(error: PlaybackException) {
        if (prerollActive || fullscreenPlayerParent != null) {
            prerollActive = false
            findViewById<View>(R.id.player_preroll_video_host).animate().cancel()
            restoreFullscreenPlayer()
            prerollView?.visibility = View.GONE
            playerView?.useController = true
        }
        retryJob?.cancel()
        stablePlaybackJob?.cancel()
        // The skip is over however it ended; nothing after this may be withheld on its
        // account, least of all the reconnecting notice or the error screen.
        endSeekBuffering()
        val failure = describePlaybackFailure(error.errorCode)
        val positionMs = player?.currentPosition ?: 0L
        Log.e(
            PLAYBACK_LOG_TAG,
            "event=error item=${itemId.orEmpty()} code=${error.errorCode} " +
                "name=${error.errorCodeName} positionMs=$positionMs " +
                "bufferedMs=${player?.bufferedPosition ?: 0L} attempt=$automaticRetryAttempt",
            error,
        )

        val nextAttempt = automaticRetryAttempt + 1
        val delayMs = automaticRetryDelayMs(nextAttempt)
        if (failure.canAutoRetry && delayMs != null) {
            automaticRetryAttempt = nextAttempt
            val seconds = (delayMs / 1_000L).coerceAtLeast(1L)
            showPlaybackLoading(
                title = getString(R.string.playback_reconnecting),
                hint = resources.getQuantityString(
                    R.plurals.playback_retrying_in,
                    seconds.toInt(),
                    seconds,
                ),
            )
            retryJob = lifecycleScope.launch {
                delay(delayMs)
                retryPlayback(
                    refreshSource = failure.requiresTranscode ||
                        failure.requiresFreshStream ||
                        nextAttempt >= FRESH_STREAM_RETRY_ATTEMPT,
                    forceTranscode = failure.requiresTranscode,
                )
            }
            return
        }

        showPlaybackError(failure)
    }

    private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
        retryJob?.cancel()
        hidePlaybackError()
        val playback = player ?: return
        // A launch whose resolution failed has no media item to re-prepare and no playhead
        // to preserve: the retry is the request that never succeeded.
        pendingRequest?.let { request ->
            resolvePendingStream(request, playWhenReady = true)
            return
        }
        val positionMs = playback.currentPosition.coerceAtLeast(0L)
        showPlaybackLoading(
            title = getString(R.string.playback_reconnecting),
            hint = getString(
                if (refreshSource) R.string.playback_refreshing_stream else R.string.playback_retrying_now,
            ),
        )
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=retry item=${itemId.orEmpty()} attempt=$automaticRetryAttempt " +
                "freshStream=$refreshSource positionMs=$positionMs",
        )

        val id = itemId
        if (!refreshSource || id.isNullOrBlank()) {
            playback.seekTo(positionMs)
            playback.playWhenReady = true
            playback.prepare()
            return
        }

        retryJob = lifecycleScope.launch {
            runCatching {
                ServiceLocator.repository.refreshPlayableStream(
                    itemId = id,
                    title = playbackTitle,
                    resumePositionMs = positionMs,
                    forceTranscode = forceTranscode,
                )
            }.onSuccess { refreshed ->
                if (isFinishing || isDestroyed) return@onSuccess
                itemId = refreshed.itemId
                mediaSourceId = refreshed.mediaSourceId
                playSessionId = refreshed.playSessionId
                playMethod = refreshed.playMethod
                availableSubtitles = refreshed.subtitles
                subtitlePreference = refreshed.subtitlesEnabled
                serverSubtitleId = refreshed.selectedSubtitleId
                if (refreshed.title.isNotBlank()) playbackTitle = refreshed.title
                subtitleAutoSelectionAttempted = false
                Log.i(
                    PLAYBACK_LOG_TAG,
                    "event=subtitle_configs item=${refreshed.itemId} count=${refreshed.subtitles.size} source=refresh",
                )
                playback.setMediaItem(mediaItem(refreshed.url, refreshed.subtitles), positionMs)
                playback.playWhenReady = true
                playback.prepare()
            }.onFailure { refreshError ->
                Log.e(
                    PLAYBACK_LOG_TAG,
                    "event=stream_refresh_failed item=$id attempt=$automaticRetryAttempt",
                    refreshError,
                )
                showPlaybackError(
                    PlaybackFailure(
                        title = getString(R.string.playback_server_unreachable),
                        detail = getString(R.string.playback_server_unreachable_detail),
                        canAutoRetry = false,
                    ),
                )
            }
        }
    }

    private fun showPlaybackError(failure: PlaybackFailure) {
        hidePlaybackLoading()
        playerView?.hideController()
        errorTitleView?.text = failure.title
        errorDetailView?.text = failure.detail
        errorView?.apply {
            visibility = View.VISIBLE
            findViewById<View>(R.id.playback_error_retry).requestFocus()
        }
        alertsSuppressed.value = true
    }

    private fun hidePlaybackError() {
        errorView?.visibility = View.GONE
        alertsSuppressed.value = loadingView?.visibility == View.VISIBLE
    }

    private fun scheduleRetryBudgetReset() {
        stablePlaybackJob?.cancel()
        stablePlaybackJob = lifecycleScope.launch {
            delay(STABLE_PLAYBACK_RESET_MS)
            automaticRetryAttempt = 0
            Log.i(PLAYBACK_LOG_TAG, "event=recovery_budget_reset item=${itemId.orEmpty()}")
        }
    }

    private fun recordPlaybackState(state: Int) {
        val now = SystemClock.elapsedRealtime()
        if (state == Player.STATE_BUFFERING && bufferingStartedAtMs == null) {
            bufferingStartedAtMs = now
            bufferingCount += 1
        } else if (state != Player.STATE_BUFFERING) {
            bufferingStartedAtMs?.let { totalBufferingMs += now - it }
            bufferingStartedAtMs = null
        }
        Log.d(
            PLAYBACK_LOG_TAG,
            "event=state item=${itemId.orEmpty()} state=${playbackStateName(state)} " +
                "positionMs=${player?.currentPosition ?: 0L}",
        )
    }

    private fun updateStreamStatus(tracks: Tracks) {
        val selected = tracks.groups.flatMap { group ->
            (0 until group.length)
                .filter(group::isTrackSelected)
                .map(group::getTrackFormat)
        }
        val video = selected.firstOrNull { it.sampleMimeType?.startsWith("video/") == true }
        val audio = selected.firstOrNull { it.sampleMimeType?.startsWith("audio/") == true }
        val resolution = when {
            video == null || video.height <= 0 -> null
            video.height >= 2160 -> "4K"
            else -> "${video.height}p"
        }
        val codec = video?.sampleMimeType
            ?.substringAfter('/')
            ?.replace("x-", "")
            ?.uppercase()
        val channels = audio?.channelCount?.takeIf { it > 0 }?.let { "$it CH" }
        val label = listOfNotNull(resolution, codec, channels).joinToString("  •  ")
        streamStatusView?.apply {
            text = label
            visibility = if (label.isBlank()) View.GONE else View.VISIBLE
        }
        if (label.isNotBlank()) {
            Log.i(PLAYBACK_LOG_TAG, "event=tracks item=${itemId.orEmpty()} selected=\"$label\"")
        }
    }

    @OptIn(UnstableApi::class)
    private fun ensureSubtitleSelected(tracks: Tracks) {
        val textTracks = buildList {
            tracks.groups.forEach { group ->
                if (group.type != C.TRACK_TYPE_TEXT) return@forEach
                for (index in 0 until group.mediaTrackGroup.length) {
                    if (group.isTrackSupported(index)) add(TrackChoice(group.mediaTrackGroup, index, ""))
                }
            }
        }
        val selectedCount = tracks.groups.sumOf { group ->
            if (group.type != C.TRACK_TYPE_TEXT) 0
            else (0 until group.mediaTrackGroup.length).count(group::isTrackSelected)
        }
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=subtitle_tracks item=${itemId.orEmpty()} available=${textTracks.size} selected=$selectedCount",
        )
        if (subtitleAutoSelectionAttempted || textTracks.isEmpty()) return
        subtitleAutoSelectionAttempted = true
        val playback = player ?: return
        if (subtitlePreference == false) {
            // Declining to choose is not enough: media3 selects a default-flagged text
            // track on its own, so somebody who turned subtitles off would still get them.
            if (selectedCount > 0) {
                playback.trackSelectionParameters = playback.trackSelectionParameters.buildUpon()
                    .clearOverridesOfType(C.TRACK_TYPE_TEXT)
                    .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
                    .build()
            }
            Log.i(PLAYBACK_LOG_TAG, "event=subtitle_disabled item=${itemId.orEmpty()}")
            return
        }
        if (selectedCount > 0) return

        val choice = preferredTextTrack(textTracks) ?: return
        val group = choice.group ?: return
        playback.trackSelectionParameters = playback.trackSelectionParameters.buildUpon()
            .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
            .clearOverridesOfType(C.TRACK_TYPE_TEXT)
            .setOverrideForType(TrackSelectionOverride(group, listOf(choice.index)))
            .build()
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=subtitle_auto_selected item=${itemId.orEmpty()} track=${choice.index} " +
                "server=${serverSubtitleId.ifBlank { "-" }}",
        )
    }

    /**
     * Which of the player's own text tracks to turn on.
     *
     * The gateway's answer wins when one of these tracks *is* it — matched on the id the
     * subtitle configuration was built with. It can fail to match, and legitimately: a
     * container's embedded track is media3's own, not a sidecar Memby named. So the same
     * rule the gateway applied ([selectSubtitleId]) runs here over the real tracks as the
     * fallback, against the same synced settings, rather than a different heuristic.
     */
    @OptIn(UnstableApi::class)
    private fun preferredTextTrack(tracks: List<TrackChoice>): TrackChoice? {
        if (serverSubtitleId.isNotBlank()) {
            tracks.firstOrNull { it.group?.getFormat(it.index)?.id == serverSubtitleId }
                ?.let { return it }
        }
        val settings = ServiceLocator.settings.current
        val candidates = tracks.mapIndexed { position, track ->
            val format = track.group?.getFormat(track.index)
            SubtitleCandidate(
                id = position.toString(),
                language = format?.language,
                isDefault = (format?.selectionFlags ?: 0) and C.SELECTION_FLAG_DEFAULT != 0,
                isForced = (format?.selectionFlags ?: 0) and C.SELECTION_FLAG_FORCED != 0,
                isHearingImpaired = (format?.roleFlags ?: 0) and C.ROLE_FLAG_CAPTION != 0,
            )
        }
        val chosen = selectSubtitleId(
            candidates = candidates,
            enabled = true,
            language = settings?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
        ) ?: return null
        return tracks.getOrNull(chosen.toIntOrNull() ?: return null)
    }

    /**
     * Remembers what the viewer just chose, on their account rather than on this television.
     *
     * The write is local and immediate; PreferencesSync pushes it up behind them. A track
     * with no language of its own leaves the stored language alone — turning subtitles back
     * on with an unlabelled track must not erase the fact that somebody chose Italian.
     */
    @OptIn(UnstableApi::class)
    private fun rememberSubtitleChoice(enabled: Boolean, language: String?) {
        lifecycleScope.launch {
            runCatching { ServiceLocator.settings.setSubtitlePreference(enabled, language) }
                .onFailure {
                    Log.w(PLAYBACK_LOG_TAG, "event=subtitle_preference_save_failed", it)
                }
        }
    }

    private fun bindTitleArtwork(title: String, logoUrl: String?) {
        val logo = findViewById<ImageView>(R.id.player_title_logo)
        val fallback = findViewById<TextView>(R.id.player_title).apply {
            text = title.ifBlank { "Now playing" }
        }
        if (logoUrl.isNullOrBlank()) {
            logo.clearColorFilter()
            logo.visibility = View.GONE
            fallback.visibility = View.VISIBLE
            return
        }
        logo.load(logoUrl) {
            crossfade(false)
            listener(
                onSuccess = { _, result ->
                    makeLogoVisibleOnDarkBackground(logo, result.drawable)
                    logo.visibility = View.VISIBLE
                    fallback.visibility = View.GONE
                },
                onError = { _, _ ->
                    logo.visibility = View.GONE
                    fallback.visibility = View.VISIBLE
                },
            )
        }
    }

    private fun setUpPlaybackIdentity(title: String) {
        playbackIdentityView = findViewById(R.id.player_playback_identity)
        findViewById<TextView>(R.id.player_playback_identity_title).apply {
            text = title.ifBlank { "Now playing" }
            visibility = View.VISIBLE
        }
    }

    private fun showPlaybackIdentity() {
        if (playbackIdentityShown) return
        val identity = playbackIdentityView ?: return
        playbackIdentityShown = true
        playbackIdentityHideJob?.cancel()
        identity.animate().cancel()
        identity.alpha = 0f
        identity.visibility = View.VISIBLE
        identity.animate()
            .alpha(1f)
            .setDuration(PLAYBACK_IDENTITY_FADE_MS)
            .start()
        playbackIdentityHideJob = lifecycleScope.launch {
            delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS)
            identity.animate()
                .alpha(0f)
                .setDuration(PLAYBACK_IDENTITY_FADE_MS)
                .withEndAction { identity.visibility = View.GONE }
                .start()
        }
    }

    private fun updatePlaybackTiming(playback: Player) {
        val duration = playback.duration
        if (duration == C.TIME_UNSET || duration <= 0L) {
            remainingView?.setText(
                if (playback.isCurrentMediaItemLive) R.string.player_live else R.string.player_loading_duration,
            )
            finishTimeView?.text = ""
            return
        }

        val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
        val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
        val wallClockRemainingMs = (remainingMs / speed).toLong()
        remainingView?.text = formatRemaining(wallClockRemainingMs)
        val finishAt = System.currentTimeMillis() + wallClockRemainingMs
        finishTimeView?.text = getString(
            R.string.player_ends_at,
            DateFormat.getTimeFormat(this).format(Date(finishAt)),
        )
        updateTimeRemainingCue(remainingMs)
    }

    // --- Time remaining cue -------------------------------------------------------

    private fun setUpTimeRemainingCue() {
        timeRemainingCue = findViewById(R.id.player_time_remaining)
        timeRemainingLabel = findViewById(R.id.player_time_remaining_label)
        timeRemainingValue = findViewById(R.id.player_time_remaining_value)
    }

    private fun setUpSeasonFinaleCue() {
        seasonFinaleCue = findViewById(R.id.player_season_finale)
        seasonFinaleValue = findViewById(R.id.player_season_finale_value)
    }

    private fun prefetchSeasonFinale() {
        seasonFinaleJob?.cancel()
        seasonFinaleInfo = null
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        seasonFinaleJob = lifecycleScope.launch {
            val finale = runCatching { ServiceLocator.repository.seasonFinale(id) }
                .getOrNull()
                ?.takeIf { it.seasonFinale }
                ?: return@launch
            if (itemId != id) return@launch
            seasonFinaleInfo = finale
            // Usually this is ready before the ordinary start cue. A slow metadata call
            // must not lose the news, so show it as soon as the player is otherwise ready.
            if (playbackStartCueShown && timingCueCanShow()) showSeasonFinaleCue(finale)
        }
    }

    private fun showSeasonFinaleCue(finale: GatewaySeasonFinale) {
        if (seasonFinaleShown || !finale.seasonFinale) return
        val cue = seasonFinaleCue ?: return
        seasonFinaleShown = true
        seasonFinaleValue?.text = getString(
            R.string.player_season_finale_value,
            finale.seriesName.ifBlank { playbackTitle },
            finale.seasonNumber,
        )
        seasonFinaleHideJob?.cancel()
        cue.animate().cancel()
        cue.alpha = 0f
        cue.translationY = dp(TIME_REMAINING_TRAVEL_DP).toFloat()
        cue.visibility = View.VISIBLE
        cue.animate()
            .alpha(1f)
            .translationY(0f)
            .setDuration(TIME_REMAINING_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()
        seasonFinaleHideJob = lifecycleScope.launch {
            delay(TIME_REMAINING_VISIBLE_MS)
            cue.animate()
                .alpha(0f)
                .translationY(dp(TIME_REMAINING_TRAVEL_DP).toFloat())
                .setDuration(TIME_REMAINING_ANIMATION_MS)
                .setInterpolator(DecelerateInterpolator())
                .withEndAction {
                    cue.visibility = View.GONE
                    cue.alpha = 1f
                    cue.translationY = 0f
                }
                .start()
        }
    }

    /** Counts actual playing time, so buffering and pause do not consume the ten seconds. */
    private fun startPlaybackStartCueWatch() {
        playbackStartCueJob?.cancel()
        playbackStartCueJob = lifecycleScope.launch {
            var playedMs = 0L
            var lastTickMs = SystemClock.elapsedRealtime()
            while (isActive && !playbackStartCueShown) {
                delay(PLAYBACK_START_CUE_TICK_MS)
                val nowMs = SystemClock.elapsedRealtime()
                if (
                    player?.isPlaying == true &&
                    lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
                ) {
                    playedMs += nowMs - lastTickMs
                }
                lastTickMs = nowMs
                if (playbackStartCueReady(playedMs, initialResumePositionMs)) {
                    player?.let(::updatePlaybackStartCue)
                }
            }
        }
    }

    private fun updatePlaybackStartCue(playback: Player) {
        if (playbackStartCueShown || !timingCueCanShow()) return
        val duration = playback.duration
        if (duration == C.TIME_UNSET || duration <= 0L || playback.isCurrentMediaItemLive) return
        val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
        if (remainingMs == 0L) return
        val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
        val wallClockRemainingMs = (remainingMs / speed).toLong()
        playbackStartCueShown = true
        seasonFinaleInfo?.let(::showSeasonFinaleCue)
        if (initialResumePositionMs > 0L) {
            showTimingCue(
                label = getString(R.string.player_resume_time_left_label),
                value = formatCueDuration(wallClockRemainingMs),
            )
        } else {
            val finishAt = System.currentTimeMillis() + wallClockRemainingMs
            showTimingCue(
                label = getString(R.string.player_finishes_in_label),
                value = getString(
                    R.string.player_finishes_in_value,
                    formatCueDuration(wallClockRemainingMs),
                    DateFormat.getTimeFormat(this).format(Date(finishAt)),
                ),
            )
        }
    }

    /**
     * Announces the ten-minute boundary once per item. Media time is intentional: pause
     * freezes the boundary and playback speed does not make a ten-minute story segment
     * become five minutes of content.
     */
    private fun updateTimeRemainingCue(remainingMs: Long) {
        if (!playbackStartCueShown || timeRemainingShown || timeRemainingHideJob?.isActive == true) return
        if (ServiceLocator.settings.current?.showTenMinuteReminder == false) return
        val minutes = timeRemainingCueMinutes(remainingMs) ?: return
        if (!timingCueCanShow()) return
        timeRemainingShown = true
        showTimingCue(
            label = getString(R.string.player_time_remaining_label),
            value = resources.getQuantityString(
                R.plurals.player_time_remaining_cue,
                minutes,
                minutes,
            ),
        )
    }

    private fun timingCueCanShow(): Boolean =
        !prerollActive &&
            prerollView?.isVisible != true &&
            loadingView?.isVisible != true &&
            errorView?.isVisible != true

    private fun showTimingCue(label: String, value: String) {
        val cue = timeRemainingCue ?: return
        timeRemainingLabel?.text = label
        timeRemainingValue?.text = value
        timeRemainingHideJob?.cancel()
        cue.animate().cancel()
        cue.alpha = 0f
        cue.translationY = dp(TIME_REMAINING_TRAVEL_DP).toFloat()
        cue.visibility = View.VISIBLE
        cue.animate()
            .alpha(1f)
            .translationY(0f)
            .setDuration(TIME_REMAINING_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()

        timeRemainingHideJob = lifecycleScope.launch {
            delay(TIME_REMAINING_VISIBLE_MS)
            cue.animate()
                .alpha(0f)
                .translationY(dp(TIME_REMAINING_TRAVEL_DP).toFloat())
                .setDuration(TIME_REMAINING_ANIMATION_MS)
                .setInterpolator(DecelerateInterpolator())
                .withEndAction {
                    cue.visibility = View.GONE
                    cue.alpha = 1f
                    cue.translationY = 0f
                }
                .start()
        }
    }

    private fun resetTimeRemainingCue() {
        playbackStartCueJob?.cancel()
        playbackStartCueJob = null
        timeRemainingHideJob?.cancel()
        timeRemainingHideJob = null
        playbackStartCueShown = false
        timeRemainingShown = false
        seasonFinaleJob?.cancel()
        seasonFinaleJob = null
        seasonFinaleHideJob?.cancel()
        seasonFinaleHideJob = null
        seasonFinaleInfo = null
        seasonFinaleShown = false
        seasonFinaleCue?.apply {
            animate().cancel()
            visibility = View.GONE
            alpha = 1f
            translationY = 0f
        }
        timeRemainingCue?.apply {
            animate().cancel()
            visibility = View.GONE
            alpha = 1f
            translationY = 0f
        }
    }

    // --- Skipping -------------------------------------------------------------------

    /**
     * True when Left and Right have nothing else to mean, and there is something to seek.
     *
     * The transport being up is the important half: with the controls visible those keys
     * belong to whatever holds focus — the time bar, or the row of buttons — and taking
     * them would leave the viewer unable to reach the subtitle or cast controls at all.
     * A stream that cannot be sought (a live recording still being written, a container
     * with no duration yet) falls through to media3 instead, which shows the controls;
     * nothing errors and nothing pretends to have skipped.
     */
    private fun seekControlsActive(): Boolean {
        val playback = player ?: return false
        return playbackStarted &&
            !prerollActive &&
            playback.isCurrentMediaItemSeekable &&
            playback.duration > 0L &&
            playback.duration != C.TIME_UNSET &&
            playerView?.isControllerFullyVisible != true &&
            castOverlay?.isVisible != true &&
            subtitleOverlay?.isVisible != true &&
            nextUpBanner?.isVisible != true &&
            creditsView?.isVisible != true &&
            loadingView?.isVisible != true &&
            errorView?.isVisible != true
    }

    /**
     * Wires the strip above the time bar to media3's own scrubbing.
     *
     * The transport being up is what makes this a second implementation rather than a
     * branch of [nudgeSeek]: those presses are the time bar's, and it is the one thing that
     * knows where a scrub has reached. So the frames follow *it* — [TimeBar.OnScrubListener]
     * is the same contract media3's own position text is updated from, which is why the
     * strip and the clock beneath it can never disagree about where the viewer is.
     *
     * Both views belong to the controller layout, so a controller that hides takes the
     * strip with it and there is no visibility of ours to keep in step.
     */
    @OptIn(UnstableApi::class)
    private fun bindScrubPreview(view: PlayerView) {
        val preview = view.findViewById<ImageView>(R.id.player_scrub_preview) ?: return
        val bar = view.findViewById<View>(androidx.media3.ui.R.id.exo_progress) ?: return
        scrubPreview = preview
        scrubAnchor = bar
        trickplayPreview.bind(preview)
        (bar as? TimeBar)?.addListener(object : TimeBar.OnScrubListener {
            override fun onScrubStart(timeBar: TimeBar, position: Long) {
                scrubPositionMs = position
                showScrubPreview(position, forward = true)
            }

            override fun onScrubMove(timeBar: TimeBar, position: Long) {
                val forward = position >= scrubPositionMs
                scrubPositionMs = position
                showScrubPreview(position, forward)
            }

            override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
                // The seek media3 is about to make is the viewer's answer; the frame that
                // asked the question has nothing left to say. It goes down here rather than
                // on a timer, so it can never outlive the scrub that raised it.
                trickplayPreview.hide()
            }
        })
    }

    /**
     * Draws the frame under the scrubber and slides the strip to sit above it.
     *
     * The horizontal placement is computed rather than fixed, because a thumbnail parked in
     * the middle of the screen while the scrubber is at the far end is a picture of some
     * other moment. It is clamped to the bar, so the two ends of a film do not push it off
     * the edge — where overscan would take it — and it is centred on the scrubber otherwise.
     */
    private fun showScrubPreview(positionMs: Long, forward: Boolean) {
        val preview = scrubPreview ?: return
        val bar = scrubAnchor ?: return
        val parent = preview.parent as? View ?: return
        val durationMs = player?.duration ?: return
        if (durationMs <= 0L || durationMs == C.TIME_UNSET) return
        val track = bar.width - bar.paddingLeft - bar.paddingRight
        val width = preview.width.takeIf { it > 0 } ?: preview.layoutParams?.width ?: 0
        if (track > 0 && width > 0) {
            // Measured in window coordinates rather than from either view's own left,
            // because the bar is two levels down inside the controls column and the strip
            // hangs off the controller root: only the window is a frame both agree on.
            val barAt = IntArray(2).also(bar::getLocationInWindow)
            val parentAt = IntArray(2).also(parent::getLocationInWindow)
            val left = barAt[0] - parentAt[0] + bar.paddingLeft
            val fraction = (positionMs.toFloat() / durationMs).coerceIn(0f, 1f)
            val centre = left + track * fraction - width / 2f
            preview.translationX = centre.coerceIn(
                left.toFloat(),
                (left + track - width).toFloat().coerceAtLeast(left.toFloat()),
            )
        }
        trickplayPreview.show(positionMs, forward, into = preview)
    }

    private fun seekIntervalMs(): Long =
        normalizeSeekIntervalSeconds(
            ServiceLocator.settings.current?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS,
        ) * 1_000L

    /**
     * One press. The seek itself is deferred by [SEEK_COMMIT_DELAY_MS] so that four quick
     * presses are one seek of two minutes rather than four the viewer waits through in
     * turn — seeking over HTTP costs several requests before a frame is decoded.
     */
    private fun nudgeSeek(forward: Boolean) {
        val playback = player ?: return
        val durationMs = playback.duration
        val preview = accumulateSeek(
            previous = seekPreview,
            positionMs = playback.currentPosition,
            durationMs = durationMs,
            stepMs = seekIntervalMs(),
            forward = forward,
        )
        seekPreview = preview
        showSeekIndicator(preview, durationMs)
        seekCommitJob?.cancel()
        seekCommitJob = lifecycleScope.launch {
            delay(SEEK_COMMIT_DELAY_MS)
            commitSeek()
        }
    }

    /**
     * Lands the pending seek. Playback state is carried across deliberately: somebody who
     * skipped while paused stays paused, and somebody who skipped mid-scene is playing
     * again the moment the decoder has the frame — a seek must never be the thing that
     * leaves a film sitting still.
     */
    private fun commitSeek() {
        seekCommitJob?.cancel()
        seekCommitJob = null
        val target = seekPreview?.targetMs ?: return
        seekPreview = null
        val playback = player ?: return
        val wasPlaying = playback.playWhenReady
        // Claim the buffering the seek is about to cause before asking for it, or the
        // state change lands first and puts the loading overlay up anyway.
        seekBuffering = true
        seekLoadingFallbackJob?.cancel()
        seekLoadingFallbackJob = lifecycleScope.launch {
            delay(SEEK_LOADING_GRACE_MS)
            if (seekBuffering) showPlaybackLoading()
        }
        // Hold the OSD up while the decoder catches up: it is the only thing on screen
        // saying the skip happened, and it must not go dark before the frame it names.
        seekHideJob?.cancel()
        seekHideJob = null
        playback.seekTo(target)
        if (wasPlaying) playback.play()
        Log.i(PLAYBACK_LOG_TAG, "event=seek item=${itemId.orEmpty()} positionMs=$target")
    }

    /** The decoder has the frame the skip asked for (or the film moved on without it). */
    private fun endSeekBuffering() {
        seekLoadingFallbackJob?.cancel()
        seekLoadingFallbackJob = null
        if (!seekBuffering) return
        seekBuffering = false
        // Only now does the indicator start its clock, so the time it is readable for is
        // the time it is readable for, however long the seek itself took.
        if (seekPreview == null && seekIndicator?.isVisible == true) hideSeekIndicatorAfterDelay()
    }

    private fun showSeekIndicator(preview: SeekPreview, durationMs: Long) {
        val indicator = seekIndicator ?: findViewById<View>(R.id.player_seek_indicator)?.also {
            seekIndicator = it
            seekGlyph = it.findViewById(R.id.player_seek_glyph)
            seekAmountView = it.findViewById(R.id.player_seek_amount)
            seekPositionView = it.findViewById(R.id.player_seek_position)
            trickplayPreview.bind(it.findViewById(R.id.player_seek_preview))
        } ?: return
        val rewinding = preview.offsetMs < 0L
        trickplayPreview.show(preview.targetMs, forward = !rewinding)
        seekGlyph?.setImageResource(
            if (rewinding) R.drawable.ic_player_rewind else R.drawable.ic_player_forward,
        )
        seekAmountView?.text = getString(
            if (rewinding) R.string.player_seek_back else R.string.player_seek_forward,
            seekAmountLabel(preview.offsetMs),
        )
        seekPositionView?.text = seekPositionLabel(preview.targetMs, durationMs)
        seekHideJob?.cancel()
        indicator.animate().cancel()
        // No slide and no fade in: the OSD has to be on screen for the press that produced
        // it, not a moment later, or a second press lands before the first is legible.
        indicator.alpha = 1f
        indicator.visibility = View.VISIBLE
        hideSeekIndicatorAfterDelay()
    }

    private fun hideSeekIndicatorAfterDelay() {
        val indicator = seekIndicator ?: return
        seekHideJob?.cancel()
        seekHideJob = lifecycleScope.launch {
            delay(SEEK_INDICATOR_VISIBLE_MS)
            indicator.animate()
                .alpha(0f)
                .setDuration(TIME_REMAINING_ANIMATION_MS)
                .setInterpolator(DecelerateInterpolator())
                .withEndAction {
                    indicator.visibility = View.GONE
                    indicator.alpha = 1f
                    // The chip is coming back for the next press, and it must come back
                    // wordless rather than carrying the frame from the last one.
                    trickplayPreview.hide()
                }
                .start()
        }
    }

    /** Drops a pending skip and takes the OSD down. Used when the item underneath changes. */
    private fun resetSeekControls() {
        seekCommitJob?.cancel()
        seekCommitJob = null
        seekHideJob?.cancel()
        seekHideJob = null
        seekPreview = null
        seekBuffering = false
        seekLoadingFallbackJob?.cancel()
        seekLoadingFallbackJob = null
        seekIndicator?.apply {
            animate().cancel()
            visibility = View.GONE
            alpha = 1f
        }
        // This runs when the item underneath changes, so the outgoing episode's thumbnails
        // must go with it — the next press is about a different programme.
        trickplayPreview.reset()
    }

    // --- Skipping the opening titles ------------------------------------------------

    private fun setUpSkipIntro() {
        skipIntroView = findViewById(R.id.player_skip_intro)
        skipIntroLabel = findViewById(R.id.player_skip_intro_label)
        skipIntroCountdown = findViewById(R.id.player_skip_intro_countdown)
        skipIntroButton = findViewById<View>(R.id.player_skip_intro_button)?.also { button ->
            button.setOnClickListener { performIntroSkip(automatic = false) }
        }
    }

    /**
     * Asks where this episode's titles are, in the background.
     *
     * Deliberately not on the path of the Play press. Reading the markers costs a round
     * trip — to the gateway, which reads them from Emby, or to Emby directly — and nothing
     * about a skip button is wanted before the first frame: the earliest intro in a typical
     * library starts a couple of minutes in. So this runs beside the cast lookup and the
     * preview manifest, once there is a picture.
     *
     * An episode with no markers, a film, and a server that will not answer are all the
     * same answer — null — and every one of them simply means no button.
     */
    private fun loadIntroSegment() {
        skipIntroLookupJob?.cancel()
        skipIntroSegment = null
        if (!skipIntroAvailable) return
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        skipIntroLookupJob = lifecycleScope.launch {
            val segment = runCatching { ServiceLocator.repository.introSegment(id) }.getOrNull()
            // Auto-advance may have moved on to the next episode while this was in flight,
            // and its titles are somewhere else entirely.
            if (itemId == id) skipIntroSegment = segment
        }
    }

    /**
     * Watches the playhead rather than a timer of its own, the way the next-up countdown
     * does. Pausing inside the titles leaves the offer up, and seeking out of them takes it
     * down — both of which fall out of reading the position rather than counting seconds.
     */
    private fun startSkipIntroWatch() {
        skipIntroJob?.cancel()
        skipIntroJob = lifecycleScope.launch {
            while (isActive) {
                delay(SKIP_INTRO_TICK_MS)
                updateSkipIntroFromPlayhead()
            }
        }
    }

    private fun updateSkipIntroFromPlayhead() {
        if (advancing || returningHomeAfterCompletion) return
        val playback = player ?: return
        val segment = skipIntroSegment ?: return
        val mode = normalizeSkipIntroMode(ServiceLocator.settings.current?.skipIntroMode)
        if (mode == SKIP_INTRO_OFF) {
            hideSkipIntro()
            return
        }
        val positionMs = playback.currentPosition
        // The tail is what stops the offer appearing for the last moment of a sequence,
        // where pressing it would be indistinguishable from doing nothing.
        if (!segment.contains(positionMs, lead = SKIP_INTRO_TAIL_MS)) {
            hideSkipIntro()
            // Rewinding to before the titles offers them again: somebody who went back
            // there did it on purpose, and the button they dismissed a minute ago is the
            // one they now want. Only a dismissal is re-armed — see [skipIntroTaken].
            if (positionMs < segment.startMs) skipIntroDismissed = false
            return
        }
        when {
            skipIntroTaken || skipIntroDismissed -> Unit
            mode == SKIP_INTRO_AUTO -> performIntroSkip(automatic = true)
            skipIntroCanShow() -> {
                showSkipIntro()
                // Every tick, not only on the first: the ring is the offer's own clock and
                // showSkipIntro does nothing once the button is already up.
                updateSkipIntroCountdown(segment, positionMs)
            }
            // Something else owns the screen — the transport, an overlay, the pre-roll.
            // The offer waits rather than being spent behind whatever is in front of it.
            else -> hideSkipIntro()
        }
    }

    /**
     * Advances the ring on the button.
     *
     * It counts down the *offer*, not the title sequence: from where the button appears to
     * where it goes, which is [SKIP_INTRO_TAIL_MS] short of the end of the intro. The two
     * are seconds apart and only one of them can be drawn honestly — a ring measuring the
     * whole sequence would stop with a sliver left and vanish mid-sweep, which reads as the
     * countdown having broken rather than as the offer having lapsed. What it says is
     * therefore exactly what it looks like it says: how long is left to press this.
     */
    private fun updateSkipIntroCountdown(segment: IntroSegment, positionMs: Long) {
        val countdown = skipIntroCountdown ?: return
        val offerEndMs = segment.endMs - SKIP_INTRO_TAIL_MS
        countdown.setRemaining(
            remainingMs = offerEndMs - positionMs,
            totalMs = offerEndMs - segment.startMs,
        )
    }

    /**
     * True when there is nothing else the viewer is looking at.
     *
     * The button takes focus, so it must never appear over something that already has it:
     * the transport row, the subtitle drop-up, the cast panel and the next-up banner all
     * own the remote while they are up, and stealing focus out from under any of them is
     * how a press lands somewhere nobody aimed it.
     */
    private fun skipIntroCanShow(): Boolean =
        playbackStarted &&
            !prerollActive &&
            playerView?.isControllerFullyVisible != true &&
            castOverlay?.isVisible != true &&
            subtitleOverlay?.isVisible != true &&
            nextUpBanner?.isVisible != true &&
            creditsView?.isVisible != true &&
            loadingView?.isVisible != true &&
            errorView?.isVisible != true

    private fun showSkipIntro() {
        val view = skipIntroView ?: return
        if (view.isVisible) return
        dressSkipIntro(asNotice = false)
        view.alpha = 0f
        view.visibility = View.VISIBLE
        view.animate()
            .alpha(1f)
            .setDuration(SKIP_INTRO_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()
        // Focus is the only way a remote can say "press this". It is taken as the button
        // appears and handed back to the video the moment it goes.
        skipIntroButton?.requestFocus()
    }

    private fun hideSkipIntro(returnFocus: Boolean = true) {
        val view = skipIntroView ?: return
        if (!view.isVisible) return
        val hadFocus = skipIntroButton?.isFocused == true
        view.animate()
            .alpha(0f)
            .setDuration(SKIP_INTRO_ANIMATION_MS)
            .withEndAction {
                view.visibility = View.GONE
                view.alpha = 1f
            }
            .start()
        // Only if this was holding it. Taking focus back off whatever the viewer has since
        // opened would be worse than leaving it where they put it.
        if (returnFocus && hadFocus) playerView?.requestFocus()
    }

    /**
     * Jumps to the end of the titles.
     *
     * The seek goes through the same door a press of Right does — claiming [seekBuffering]
     * before asking for it — so the couple of seconds it takes to decode a frame at the new
     * position is treated as a skip landing rather than as a film that has stopped, and the
     * loading overlay stays down.
     */
    private fun performIntroSkip(automatic: Boolean) {
        val playback = player ?: return
        val segment = skipIntroSegment ?: return
        if (skipIntroTaken) return
        skipIntroTaken = true
        val wasPlaying = playback.playWhenReady
        // Claimed before the seek is asked for, or the state change lands first and puts
        // the loading overlay up anyway.
        seekBuffering = true
        seekLoadingFallbackJob?.cancel()
        seekLoadingFallbackJob = lifecycleScope.launch {
            delay(SEEK_LOADING_GRACE_MS)
            if (seekBuffering) showPlaybackLoading()
        }
        playback.seekTo(segment.endMs)
        if (wasPlaying) playback.play()
        if (automatic) {
            // An automatic skip is a jump nobody pressed a button for, so it says what it
            // did. Otherwise the picture changes for no visible reason, which reads as the
            // stream glitching rather than as a setting working.
            announceAutomaticIntroSkip()
        } else {
            hideSkipIntro()
        }
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=skip_intro item=${itemId.orEmpty()} automatic=$automatic " +
                "fromMs=${segment.startMs} toMs=${segment.endMs}",
        )
    }

    /**
     * Dresses the one view as either the offer or the notice.
     *
     * They are the same view in the same corner, and they must not look the same. The offer
     * is the green pill every other action in this app wears and takes focus; the notice is
     * the quiet near-black plate the timing cues use and takes nothing — a notice that looks
     * pressable is a viewer pressing it to find out what it does.
     */
    private fun dressSkipIntro(asNotice: Boolean) {
        val button = skipIntroButton ?: return
        skipIntroLabel?.apply {
            setText(if (asNotice) R.string.player_skip_intro_skipped else R.string.player_skip_intro)
            if (asNotice) {
                setTextColor(Color.WHITE)
            } else {
                // A state list, not a colour: the focused pill goes white, so its label has
                // to go dark with it.
                ContextCompat.getColorStateList(this@PlayerActivity, R.color.next_up_button_text)
                    ?.let(::setTextColor)
            }
        }
        button.setBackgroundResource(
            if (asNotice) R.drawable.time_remaining_cue_background
            else R.drawable.next_up_primary_button,
        )
        // The ring counts down an offer, and a notice is not one — there is nothing left to
        // press and nothing left to run out. It goes rather than freezing at zero, which
        // would read as a countdown that had stalled.
        skipIntroCountdown?.isVisible = !asNotice
        button.isFocusable = !asNotice
    }

    /** Says an automatic skip happened, in the same corner the button would have been. */
    private fun announceAutomaticIntroSkip() {
        val view = skipIntroView ?: return
        if (!skipIntroCanShow()) return
        dressSkipIntro(asNotice = true)
        view.alpha = 0f
        view.visibility = View.VISIBLE
        view.animate()
            .alpha(1f)
            .setDuration(SKIP_INTRO_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()
        lifecycleScope.launch {
            delay(SKIP_INTRO_NOTICE_MS)
            // Never focused: nothing about a notice is meant to be pressed, and taking the
            // remote to say "done" would be worse than saying nothing.
            hideSkipIntro(returnFocus = false)
        }
    }

    /** Called when the offer is refused, and when the item underneath changes. */
    private fun dismissSkipIntro() {
        skipIntroDismissed = true
        hideSkipIntro()
    }

    private fun resetSkipIntro() {
        skipIntroJob?.cancel()
        skipIntroJob = null
        skipIntroLookupJob?.cancel()
        skipIntroLookupJob = null
        skipIntroSegment = null
        skipIntroTaken = false
        skipIntroDismissed = false
        skipIntroView?.apply {
            animate().cancel()
            visibility = View.GONE
            alpha = 1f
        }
    }

    // --- Next up ------------------------------------------------------------------

    private fun setUpNextUpBanner() {
        val banner = findViewById<View>(R.id.player_next_up)
        nextUpBanner = banner
        nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
        banner.findViewById<View>(R.id.player_next_up_play).setOnClickListener {
            nextEpisode?.let(::playNext)
        }
        banner.findViewById<View>(R.id.player_next_up_dismiss).setOnClickListener {
            dismissNextUp()
        }
    }

    /**
     * Resolves what follows the current item in the background. A movie has no next
     * episode and the lookup simply comes back null, so there is nothing to special-case
     * here; the banner only ever appears when there is something real to show.
     */
    private fun prefetchNextEpisode() {
        nextEpisode = null
        val id = itemId?.takeIf { it.isNotBlank() } ?: return
        lifecycleScope.launch {
            val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode
            if (!enabled) return@launch
            val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null)
            resolved?.imageUrl?.let { imageUrl ->
                imageLoader.enqueue(
                    ImageRequest.Builder(this@PlayerActivity)
                        .data(imageUrl)
                        .size(NEXT_UP_IMAGE_PREFETCH_WIDTH, NEXT_UP_IMAGE_PREFETCH_HEIGHT)
                        .build(),
                )
            }
            // Playback may have moved on to another episode while this was in flight.
            if (itemId == id) {
                nextEpisode = resolved
                // Extremely short episodes can end before the background lookup returns.
                if (player?.playbackState == Player.STATE_ENDED && !nextUpDismissed) {
                    resolved?.let(::playNext)
                }
            }
        }
    }

    /**
     * Watches the media playhead rather than wall-clock time. This makes the transition
     * deterministic: pause freezes the countdown and seeking out of the final minute
     * restores fullscreen playback.
     */
    private fun startNextUpWatch() {
        nextUpJob?.cancel()
        nextUpJob = lifecycleScope.launch {
            while (isActive) {
                delay(NEXT_UP_TICK_MS)
                updateNextUpFromPlayhead()
            }
        }
    }

    private fun updateNextUpFromPlayhead() {
        if (advancing) return
        val playback = player ?: return
        val next = nextEpisode ?: return
        val duration = playback.duration
        if (duration == C.TIME_UNSET || duration <= 0L) return

        // Evaluated first, because whether the pane is up decides what the banner may do.
        updateEndCreditsFromPlayhead(playback, next)

        val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
        // The pane says what is on next already, and shrinks the same picture by a different
        // amount. Raising the banner over it would fight for the transform and print the
        // episode twice, so the countdown moves into the pane instead — and it is only worth
        // drawing inside the last minute, where it was always the banner's job.
        if (creditsActive) {
            creditsCountdown?.apply {
                if (remainingMs in 1L..NEXT_UP_LEAD_MS) {
                    text = getString(R.string.next_up_starting_in, ceil(remainingMs / 1_000.0).toInt())
                    visibility = View.VISIBLE
                } else {
                    visibility = View.GONE
                }
            }
            if (remainingMs == 0L) playNext(next)
            return
        }

        when {
            remainingMs > NEXT_UP_LEAD_MS -> {
                hideNextUp()
                nextUpDismissed = false
            }
            nextUpDismissed -> Unit
            remainingMs == 0L -> playNext(next)
            else -> {
                showNextUp(next)
                val seconds = ceil(remainingMs / 1_000.0).toInt()
                nextUpCountdown?.text = getString(R.string.next_up_starting_in, seconds)
            }
        }
    }

    @OptIn(UnstableApi::class)
    private fun showNextUp(next: NextEpisode) {
        val banner = nextUpBanner ?: return
        if (banner.isVisible) return

        banner.findViewById<TextView>(R.id.player_next_up_title).text =
            next.title.ifBlank { next.seriesName }
        val meta = listOfNotNull(
            next.episodeCode,
            next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() },
        ).joinToString(" · ")
        banner.findViewById<TextView>(R.id.player_next_up_meta).apply {
            text = meta
            visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
        }
        banner.findViewById<ImageView>(R.id.player_next_up_image).load(next.imageUrl) {
            crossfade(true)
        }

        banner.alpha = 0f
        banner.visibility = View.VISIBLE
        banner.post {
            zoomVideoForNextUp()
            banner.animate()
                .alpha(1f)
                .setDuration(NEXT_UP_ANIMATION_MS)
                .setInterpolator(DecelerateInterpolator())
                .start()
            playerView?.hideController()
            banner.findViewById<View>(R.id.player_next_up_play).requestFocus()
        }
    }

    private fun hideNextUp() {
        val banner = nextUpBanner ?: return
        if (!banner.isVisible) return
        banner.animate()
            .alpha(0f)
            .setDuration(NEXT_UP_ANIMATION_MS)
            .withEndAction {
                banner.visibility = View.GONE
                banner.alpha = 1f
            }
            .start()
        restoreVideoAfterNextUp()
    }

    private fun dismissNextUp() {
        nextUpDismissed = true
        nextUpJob?.cancel()
        hideNextUp()
        playerView?.requestFocus()
    }

    private fun zoomVideoForNextUp() {
        val view = playerView ?: return
        view.animate()
            .scaleX(NEXT_UP_VIDEO_SCALE)
            .scaleY(NEXT_UP_VIDEO_SCALE)
            .translationX(-view.width * NEXT_UP_VIDEO_SHIFT_X)
            .translationY(-view.height * NEXT_UP_VIDEO_SHIFT_Y)
            .setDuration(NEXT_UP_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()
    }

    private fun restoreVideoAfterNextUp() {
        playerView?.animate()
            ?.scaleX(1f)
            ?.scaleY(1f)
            ?.translationX(0f)
            ?.translationY(0f)
            ?.setDuration(NEXT_UP_ANIMATION_MS)
            ?.setInterpolator(DecelerateInterpolator())
            ?.start()
    }

    // --- Closing credits ----------------------------------------------------------------

    private fun setUpEndCredits() {
        val view = findViewById<View>(R.id.player_end_credits)
        creditsView = view
        creditsCountdown = view.findViewById(R.id.player_end_credits_countdown)
        creditsSpeedChip = view.findViewById(R.id.player_end_credits_speed)
        view.findViewById<View>(R.id.player_end_credits_play).setOnClickListener {
            nextEpisode?.let(::playNext)
        }
        view.findViewById<View>(R.id.player_end_credits_dismiss).setOnClickListener {
            dismissEndCredits()
        }
    }

    /**
     * Reads where this title's credits begin, once playback has settled.
     *
     * Free, and that is the whole reason it is here: the repository caches one reading of the
     * chapter list, so this finds the answer [loadIntroSegment] has already fetched rather
     * than making a request of its own. Fetched beside it for the same reason — nothing about
     * a credit roll is wanted before the first frame.
     *
     * A film, an episode with no marker, and a server that will not answer are all the same
     * answer, and every one of them simply means the credits play out full size.
     */
    private fun loadCreditsStart() {
        creditsStartMs = null
        if (!endCreditsAvailable) return
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        lifecycleScope.launch {
            val start = runCatching { ServiceLocator.repository.creditsStartMs(id) }.getOrNull()
            // Auto-advance may have moved on while this was in flight, and the next episode's
            // credits are somewhere else entirely.
            if (itemId == id) creditsStartMs = start
        }
    }

    /**
     * Whether the credits pane should be up, evaluated on the next-up tick.
     *
     * It rides that loop rather than starting one of its own, and reuses its `nextEpisode`
     * guard, which happens to be exactly the right gate: the pane's right-hand half *is* what
     * is on next, so a film and the last episode of a series both correctly get nothing. That
     * also means the pane inherits auto-play's switch, since a next episode is only resolved
     * when auto-play is on — deliberate, because this is the auto-advance experience and a
     * viewer who turned that off has said they want the credits.
     */
    private fun updateEndCreditsFromPlayhead(playback: Player, next: NextEpisode) {
        if (creditsDismissed) return
        if (!(ServiceLocator.settings.current?.speedUpCredits ?: true)) {
            if (creditsActive) leaveEndCredits(restoreSpeed = true)
            return
        }
        val duration = playback.duration
        if (!creditsWorthShowing(creditsStartMs, duration)) return
        val start = creditsStartMs ?: return
        if (playback.currentPosition >= start) {
            enterEndCredits(next)
        } else if (creditsActive) {
            // Seeking back out of the roll puts the picture back, the same way the next-up
            // banner and the skip button both retreat when the playhead leaves their window.
            leaveEndCredits(restoreSpeed = true)
        }
    }

    private fun enterEndCredits(next: NextEpisode) {
        val view = creditsView ?: return
        if (creditsActive) return
        creditsActive = true
        creditsEnteredAtMs = SystemClock.elapsedRealtime()

        view.findViewById<TextView>(R.id.player_end_credits_title).text =
            next.title.ifBlank { next.seriesName }
        val meta = listOfNotNull(
            next.episodeCode,
            next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() },
        ).joinToString(" · ")
        view.findViewById<TextView>(R.id.player_end_credits_meta).apply {
            text = meta
            visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
        }
        view.findViewById<ImageView>(R.id.player_end_credits_image).load(next.imageUrl) {
            crossfade(true)
        }
        creditsCountdown?.visibility = View.GONE
        updateCreditsSpeedChip(CREDITS_NORMAL_SPEED)

        view.alpha = 0f
        view.visibility = View.VISIBLE
        view.post {
            shrinkVideoForCredits()
            view.animate()
                .alpha(1f)
                .setDuration(NEXT_UP_ANIMATION_MS)
                .setInterpolator(DecelerateInterpolator())
                .start()
            playerView?.hideController()
            view.findViewById<View>(R.id.player_end_credits_play).requestFocus()
        }
        startCreditsSpeedRamp()
    }

    /**
     * Puts the picture and the speed back, without recording a decision.
     *
     * Separate from [dismissEndCredits] because the two mean different things: this is the
     * playhead having left the roll, which may happen again, while dismissing is the viewer
     * saying not to offer it for this episode at all.
     */
    private fun leaveEndCredits(restoreSpeed: Boolean) {
        creditsActive = false
        creditsSpeedJob?.cancel()
        creditsSpeedJob = null
        if (restoreSpeed) restoreNormalSpeed()
        val view = creditsView ?: return
        if (!view.isVisible) return
        view.animate()
            .alpha(0f)
            .setDuration(NEXT_UP_ANIMATION_MS)
            .withEndAction {
                view.visibility = View.GONE
                view.alpha = 1f
            }
            .start()
        restoreVideoAfterCredits()
    }

    private fun dismissEndCredits() {
        creditsDismissed = true
        leaveEndCredits(restoreSpeed = true)
        playerView?.requestFocus()
    }

    /**
     * Eases the picture up to speed rather than snapping to it.
     *
     * A short-lived job of its own rather than a branch of the next-up tick: that runs every
     * 250 ms, which over a 1.5 s ramp is six audible steps. The rule itself is pure and lives
     * in [creditsSpeedAt] — this only supplies the clock and the player.
     */
    private fun startCreditsSpeedRamp() {
        creditsSpeedJob?.cancel()
        if (!creditsSpeedIsActive(creditsSpeedCeiling)) return
        creditsSpeedJob = lifecycleScope.launch {
            while (isActive && creditsActive) {
                val elapsed = SystemClock.elapsedRealtime() - creditsEnteredAtMs
                val speed = creditsSpeedAt(elapsed, creditsSpeedCeiling)
                applyCreditsSpeed(speed)
                if (elapsed >= CREDITS_RAMP_MS) break
                delay(CREDITS_RAMP_TICK_MS)
            }
        }
    }

    private fun applyCreditsSpeed(speed: Float) {
        val playback = player ?: return
        if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return
        playback.setPlaybackSpeed(speed)
        updateCreditsSpeedChip(speed)
    }

    private fun updateCreditsSpeedChip(speed: Float) {
        val chip = creditsSpeedChip ?: return
        if (!creditsSpeedIsActive(speed)) {
            chip.visibility = View.GONE
            return
        }
        chip.text = getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
        chip.visibility = View.VISIBLE
    }

    private fun restoreNormalSpeed() {
        creditsSpeedChip?.visibility = View.GONE
        val playback = player ?: return
        if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) {
            return
        }
        playback.setPlaybackSpeed(CREDITS_NORMAL_SPEED)
    }

    /**
     * Gives up a step of speed because the stream could not hold this one.
     *
     * Doubling the speed doubles the bitrate pulled from Emby over HTTP, and a high-bitrate
     * file on a remote server may simply not sustain it. Credits that stutter read as a
     * broken app, so the ceiling falls and — because [creditsCeilingAfterStall] only ever
     * goes down — never climbs back inside this episode. Reaching normal speed leaves the
     * pane up: what is on next is still worth showing, and only the speeding up has failed.
     */
    private fun stepDownCreditsSpeed() {
        if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return
        creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling)
        creditsSpeedJob?.cancel()
        creditsSpeedJob = null
        if (!creditsSpeedIsActive(creditsSpeedCeiling)) {
            restoreNormalSpeed()
            return
        }
        // Re-ramp from where the picture already is rather than from 1×, so giving up a step
        // is a small slowing rather than a stop and a fresh acceleration.
        creditsEnteredAtMs = SystemClock.elapsedRealtime() - CREDITS_RAMP_MS
        startCreditsSpeedRamp()
    }

    private fun shrinkVideoForCredits() {
        val view = playerView ?: return
        view.animate()
            .scaleX(CREDITS_VIDEO_SCALE)
            .scaleY(CREDITS_VIDEO_SCALE)
            .translationX(-view.width * CREDITS_VIDEO_SHIFT_X)
            .translationY(0f)
            .setDuration(NEXT_UP_ANIMATION_MS)
            .setInterpolator(DecelerateInterpolator())
            .start()
    }

    private fun restoreVideoAfterCredits() {
        playerView?.animate()
            ?.scaleX(1f)
            ?.scaleY(1f)
            ?.translationX(0f)
            ?.translationY(0f)
            ?.setDuration(NEXT_UP_ANIMATION_MS)
            ?.setInterpolator(DecelerateInterpolator())
            ?.start()
    }

    /**
     * Everything about the credits, forgotten. Called when the episode underneath changes:
     * every title rolls its credits at a different point, the next one's marker is a fresh
     * lookup, and a speed left over from the outgoing episode would run the incoming one's
     * opening scene at double speed.
     */
    private fun resetEndCredits() {
        creditsSpeedJob?.cancel()
        creditsSpeedJob = null
        creditsActive = false
        creditsDismissed = false
        creditsStartMs = null
        creditsSpeedCeiling = CREDITS_TARGET_SPEED
        creditsSpeedChip?.visibility = View.GONE
        creditsView?.apply {
            animate().cancel()
            visibility = View.GONE
            alpha = 1f
        }
        restoreVideoAfterCredits()
        restoreNormalSpeed()
    }

    private fun handlePlaybackEnded() {
        when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) {
            PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext)
            PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion()
        }
    }

    /**
     * A terminal item should never leave a still frame or black player surface behind.
     * Starting a fresh Home task also covers playback launched from the screensaver and
     * deliberately clears any detail/search overlay that was behind PlayerActivity.
     */
    private fun returnHomeAfterCompletion() {
        if (returningHomeAfterCompletion || isFinishing || isDestroyed) return
        returningHomeAfterCompletion = true
        nextUpJob?.cancel()
        progressJob?.cancel()
        hideNextUp()

        val playback = player
        val completedItemId = itemId
        if (!completedItemId.isNullOrBlank() && playbackStarted && !stopReported) {
            stopReported = true
            PlaybackStopWorker.enqueue(
                this,
                playbackSession(completedItemId),
                playback?.currentPosition ?: 0L,
            )
        }
        startActivity(
            Intent(this, MainActivity::class.java).addFlags(
                Intent.FLAG_ACTIVITY_NEW_TASK or
                    Intent.FLAG_ACTIVITY_CLEAR_TASK or
                    Intent.FLAG_ACTIVITY_CLEAR_TOP,
            ),
        )
        finish()
    }

    /**
     * Rolls into [next] inside the running player rather than relaunching the activity:
     * no teardown, no black frame between episodes. The outgoing episode is reported
     * stopped first so Emby records it as finished.
     */
    private fun playNext(next: NextEpisode) {
        if (advancing || returningHomeAfterCompletion) return
        advancing = true
        nextUpJob?.cancel()
        progressJob?.cancel()
        hideNextUp()

        val playback = player
        val previousId = itemId
        if (!previousId.isNullOrBlank() && playbackStarted && !stopReported) {
            stopReported = true
            PlaybackStopWorker.enqueue(
                this,
                playbackSession(previousId),
                playback?.currentPosition ?: 0L,
            )
        }

        itemId = next.itemId
        mediaSourceId = next.mediaSourceId
        playSessionId = next.playSessionId
        playMethod = next.playMethod
        availableSubtitles = next.subtitles
        subtitlePreference = next.subtitlesEnabled
        serverSubtitleId = next.selectedSubtitleId
        stopReported = false
        playbackStarted = false
        playbackIdentityShown = false
        playbackIdentityHideJob?.cancel()
        playbackIdentityView?.apply {
            animate().cancel()
            alpha = 0f
            visibility = View.GONE
        }
        initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
        renderedFirstFrame = false
        automaticRetryAttempt = 0
        resetTimeRemainingCue()
        // A skip aimed at the outgoing episode must not land in the incoming one.
        resetSeekControls()
        // Nor may the outgoing episode's title-sequence markers: every show times its
        // opening differently, and the next episode's are a fresh lookup.
        resetSkipIntro()
        // Nor the outgoing episode's credits marker or its speed: every title rolls them at
        // a different point, and a speed left behind would run the next episode's opening
        // scene at double speed.
        resetEndCredits()
        nextEpisode = null
        nextUpDismissed = false
        requestStartedAtMs = SystemClock.elapsedRealtime()
        trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime)
        beginLaunchTrace()
        subtitleAutoSelectionAttempted = false
        // The cast reloads with the rest of the new episode's session, once it is playing.

        playbackTitle = nextTitle(next)
        bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
        setUpPlaybackIdentity(title = playbackTitle)
        hidePlaybackError()
        showPlaybackLoading()
        if (playback != null) {
            trace.mark(PlaybackTrace.STREAM_RESOLVED)
            startMedia(next.url, next.subtitles, next.resumePositionMs, playWhenReady = true)
        }
        advancing = false
    }

    private fun nextTitle(next: NextEpisode): String = buildString {
        next.seriesName.takeIf { it.isNotBlank() }?.let(::append)
        if (isNotEmpty() && next.title.isNotBlank()) append(" – ")
        append(next.title)
    }

    private fun interruptForMaintenance() {
        player?.pause()
        progressJob?.cancel()
        startActivity(
            Intent(this, MainActivity::class.java).addFlags(
                Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
            ),
        )
        finish()
    }

    private fun bindPauseOverlay(view: PlayerView) {
        pauseOverlay = view.findViewById(R.id.player_pause_overlay)
        nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
        pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
        pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
            text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
        }
        pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
            val poster = pausePosterUrl
            if (poster.isNullOrBlank()) {
                visibility = View.GONE
            } else {
                visibility = View.VISIBLE
                load(poster) { crossfade(true) }
            }
        }
    }

    private fun updatePauseOverlay(playback: Player) {
        val paused = playbackStarted && !prerollActive &&
            playback.playbackState == Player.STATE_READY && !playback.isPlaying
        pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
        nowPlayingGroup?.visibility = if (paused) View.GONE else View.VISIBLE
        if (paused) playerView?.showController()
    }

    // Activity.dispatchKeyEvent is public platform API; the @RestrictTo lives on the
    // androidx.core.app.ComponentActivity override we inherit, so overriding it is safe.
    @SuppressLint("RestrictedApi")
    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
        if (event.keyCode == KeyEvent.KEYCODE_BACK) {
            // Consume DOWN as well as UP. Letting the platform see DOWN can finish the
            // activity before our UP handler gets a chance to hide the active surface.
            if (event.action == KeyEvent.ACTION_UP) {
                when {
                    castOverlay?.isVisible == true -> hideCastOverlay()
                    // The drop-up's download half is a level of its own, so Back leaves it
                    // before it leaves the menu — one press per level, never two at once.
                    subtitleOverlay?.isVisible == true && subtitleDownloadExpanded ->
                        collapseSubtitleDownloads()
                    subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
                    nextUpBanner?.isVisible == true -> dismissNextUp()
                    // Back asks for the credits back rather than leaving the film: one press
                    // per level, the same contract every other overlay here has.
                    creditsView?.isVisible == true -> dismissEndCredits()
                    // Back refuses the offer rather than leaving the film: one press per
                    // level, the same contract the banner above and the drop-up have.
                    skipIntroView?.isVisible == true -> dismissSkipIntro()
                    playerView?.isControllerFullyVisible == true -> playerView?.hideController()
                    else -> finish()
                }
            }
            return true
        }
        if (prerollActive && event.keyCode in PREROLL_BLOCKED_KEYS) {
            // The pre-roll is intentionally non-interactive and has no transport OSD.
            return true
        }
        if (event.keyCode in CENTRE_KEYS && centrePausesPlayback()) {
            // media3's PlayerView spends the first D-pad press revealing the controls, so
            // on a remote whose one obvious button is the middle one, pausing took two
            // presses. Consume DOWN as well, or the controller appears under the UP.
            if (event.action == KeyEvent.ACTION_UP && event.repeatCount == 0) togglePlayPause()
            return true
        }
        if (event.keyCode in SEEK_BACK_KEYS || event.keyCode in SEEK_FORWARD_KEYS) {
            if (!seekControlsActive()) return super.dispatchKeyEvent(event)
            // Only discrete presses move the film. A held key repeats at the platform's
            // own rate, which on these remotes is fast enough to throw somebody minutes
            // down a film they meant to nudge — and the repeats are consumed rather than
            // passed on, or letting go would open the transport controls on top of the
            // OSD that is already answering the question.
            if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
                nudgeSeek(forward = event.keyCode in SEEK_FORWARD_KEYS)
            }
            return true
        }
        if (event.action == KeyEvent.ACTION_UP && event.keyCode in setOf(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS)) {
            showTrackMenu()
            return true
        }
        return super.dispatchKeyEvent(event)
    }

    /**
     * True only when the centre button has nothing else to mean: the transport is hidden,
     * no overlay owns the screen, and there is a running playback to act on. With the
     * controls up the press belongs to whatever button holds focus.
     */
    private fun centrePausesPlayback(): Boolean =
        playbackStarted &&
            !prerollActive &&
            player != null &&
            playerView?.isControllerFullyVisible != true &&
            castOverlay?.isVisible != true &&
            subtitleOverlay?.isVisible != true &&
            nextUpBanner?.isVisible != true &&
            // The pane's Play button holds focus while it is up, and the centre key is how a
            // remote presses what it is focused on.
            creditsView?.isVisible != true &&
            // The skip button holds focus while it is up, and the centre key is how a
            // remote presses the thing it is focused on. Pausing instead would leave the
            // one button on screen unpressable.
            skipIntroView?.isVisible != true &&
            loadingView?.isVisible != true &&
            errorView?.isVisible != true

    private fun togglePlayPause() {
        val playback = player ?: return
        if (playback.isPlaying) playback.pause() else playback.play()
        // Show the transport either way: the viewer has just asked for control, and the
        // pause overlay's own showController only fires once the player reports paused.
        playerView?.showController()
    }

    @OptIn(UnstableApi::class)
    private fun showTrackMenu() {
        val playback = player ?: return
        val audio = playback.currentTracks.groups.any { group ->
            group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported)
        }
        // Cast is deliberately absent: it has its own button in the transport row now, and
        // one thing reachable two ways is one thing whose two entry points drift apart.
        val options = buildList {
            if (audio) add("Audio")
            add("Subtitles & appearance")
            add("Picture size · ${selectedPictureMode().shortLabel}")
        }
        AlertDialog.Builder(this)
            .setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
            .setItems(options.toTypedArray()) { _, which ->
                when (options[which]) {
                    "Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
                    "Subtitles & appearance" -> showSubtitleOverlay()
                    else -> showPictureModePicker()
                }
            }
            .show()
    }

    private fun showPictureModePicker() {
        val selected = selectedPictureMode()
        AlertDialog.Builder(this)
            .setTitle("Picture size")
            .setSingleChoiceItems(
                PictureMode.entries.map(PictureMode::label).toTypedArray(),
                PictureMode.entries.indexOf(selected),
            ) { dialog, which ->
                val mode = PictureMode.entries[which]
                getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
                    .edit()
                    .putString(PICTURE_MODE_KEY, mode.key)
                    .apply()
                applyPictureMode(mode)
                dialog.dismiss()
                playerView?.hideController()
            }
            .show()
    }

    /**
     * Hosts the launcher's service-alert bar over the video.
     *
     * News about the server is worth more here than anywhere else — playback direct-plays
     * from Emby, so "the server has stopped communicating" explains a stall the viewer is
     * looking at right now. The bar is never focusable, so the transport controls keep the
     * remote; it times itself out rather than asking for a press.
     *
     * [alertsSuppressed] is what stops an alert being *used up* behind the loading or
     * error overlay: the banner records an alert as seen the moment it composes, and the
     * whole design is that an alert nobody could see waits instead.
     */
    private fun mountServiceAlerts() {
        if (serviceAlertsMounted) return
        serviceAlertsMounted = true
        findViewById<ComposeView>(R.id.player_service_alerts)?.apply {
            // The view outlives compositions across the activity's own lifecycle, and
            // the player is not a Compose screen — dispose with the view, not the window.
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                MembyTheme {
                    // This is where the outage bar earns its place: the stream comes
                    // straight from Emby, so when Emby stops answering the film stalls
                    // with nothing on screen to explain it. The news bar yields the strip
                    // while it is up — one of them says the same thing far better.
                    val outage by ServiceLocator.maintenance.embyOutage
                        .collectAsStateWithLifecycle()
                    EmbyOutageBanner(suppressed = alertsSuppressed.value)
                    ServiceAlertBanner(
                        suppressed = alertsSuppressed.value || outage != null,
                    )
                }
            }
        }
    }

    private fun selectedPictureMode(): PictureMode {
        val key = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
            .getString(PICTURE_MODE_KEY, PictureMode.AUTO.key)
        return PictureMode.entries.firstOrNull { it.key == key } ?: PictureMode.AUTO
    }

    private fun applyPictureMode(mode: PictureMode = selectedPictureMode()) {
        val zoom = shouldZoomVideo(
            modeKey = mode.key,
            width = currentVideoSize.width,
            height = currentVideoSize.height,
            pixelWidthHeightRatio = currentVideoSize.pixelWidthHeightRatio,
        )
        playerView?.resizeMode = if (zoom) {
            AspectRatioFrameLayout.RESIZE_MODE_ZOOM
        } else {
            AspectRatioFrameLayout.RESIZE_MODE_FIT
        }
    }

    @OptIn(UnstableApi::class)
    private fun applySubtitleAppearance(view: PlayerView? = playerView) {
        val preference = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
            .getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key)
        val size = SubtitleSize.entries.firstOrNull { it.key == preference } ?: SubtitleSize.MEDIUM
        view?.subtitleView?.apply {
            setStyle(
                CaptionStyleCompat(
                    Color.WHITE,
                    Color.TRANSPARENT,
                    Color.TRANSPARENT,
                    CaptionStyleCompat.EDGE_TYPE_OUTLINE,
                    Color.BLACK,
                    null,
                ),
            )
            setFractionalTextSize(size.fraction)
            // Media3 defaults to 8% of view height. An extra 1.5% is roughly 16px on
            // a 1080p TV and keeps captions clear of the physical screen edge.
            setBottomPaddingFraction(SUBTITLE_BOTTOM_PADDING_FRACTION)
        }
    }

    private fun setUpSubtitleOverlay() {
        subtitleOverlay = findViewById<View>(R.id.player_subtitle_overlay).apply {
            setOnClickListener { hideSubtitleOverlay() }
        }
    }

    private fun setUpCastOverlay() {
        castOverlay = findViewById<View>(R.id.player_cast_overlay).apply {
            setOnClickListener { hideCastOverlay() }
        }
    }

    private fun loadCast() {
        castJob?.cancel()
        castLoaded = false
        castPeople = emptyList()
        val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
            castLoaded = true
            bindCastOverlay()
            return
        }
        castJob = lifecycleScope.launch {
            val loaded = runCatching {
                resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails)
                    .take(MAX_CAST_MEMBERS)
            }.getOrDefault(emptyList())
            if (itemId == requestedItemId) {
                castPeople = loaded
                castLoaded = true
                bindCastOverlay()
            }
        }
    }

    private fun showCastOverlay() {
        playerView?.hideController()
        castOverlay?.visibility = View.VISIBLE
        bindCastOverlay()
        if (!castLoaded && castJob?.isActive != true) loadCast()
    }

    private fun hideCastOverlay() {
        castOverlay?.visibility = View.GONE
        playerView?.showController()
    }

    private fun bindCastOverlay() {
        val overlay = castOverlay ?: return
        bindCastPanel(
            overlay = overlay,
            state = CastPanelState(
                title = playbackTitle,
                members = castPeople.map { person ->
                    CastMember(
                        name = person.name,
                        role = person.role,
                        imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
                    )
                },
                loaded = castLoaded,
            ),
            // Coil is handed in rather than reached for inside the panel, which is what lets
            // the screenshot test render the same cards without a network.
            loadImage = { view, url -> view.load(url) },
        )
        if (overlay.isVisible) {
            overlay.findViewById<LinearLayout>(R.id.player_cast_people)
                .getChildAt(0)
                ?.requestFocus()
                ?: overlay.requestFocus()
        }
    }

    @OptIn(UnstableApi::class)
    private fun showSubtitleOverlay(
        focusTrack: Int? = null,
        focusSize: Int? = null,
        focusDownload: Int? = null,
    ) {
        val playback = player ?: return
        val overlay = subtitleOverlay ?: return
        val tracksContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
        val sizesContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)

        val discoveredTracks = mutableListOf<TrackChoice>()
        playback.currentTracks.groups.forEach { group ->
            if (group.type != C.TRACK_TYPE_TEXT) return@forEach
            for (index in 0 until group.mediaTrackGroup.length) {
                if (group.isTrackSupported(index)) {
                    discoveredTracks += TrackChoice(
                        group.mediaTrackGroup,
                        index,
                        trackLabel(group.mediaTrackGroup, index),
                    )
                }
            }
        }
        fun isSelected(choice: TrackChoice): Boolean =
            choice.group != null && playback.currentTracks.groups.any { current ->
                current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index)
            }
        // Media3 may expose an embedded stream and the normalized Emby sidecar as
        // equivalent tracks. Show one meaningful choice, preferring the selected copy.
        val tracks = mutableListOf(TrackChoice(null, -1, "Off")).apply {
            addAll(
                discoveredTracks.groupBy { it.label.lowercase() }.values.map { equivalents ->
                    equivalents.firstOrNull(::isSelected) ?: equivalents.first()
                },
            )
            addAll(
                availableSubtitles
                    .filter { it.deliveryMethod.equals("Encode", true) }
                    .map { subtitle ->
                        TrackChoice(
                            group = null,
                            index = subtitle.id.toIntOrNull() ?: -1,
                            label = subtitleDisplayLabel(subtitle) + " · Burned in",
                            encodedSubtitle = subtitle,
                        )
                    },
            )
        }
        val selectedTrack = tracks.indexOfFirst { choice ->
            isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId
        }.let { if (it >= 0) it else 0 }

        // Nothing but "Off" in the list means the title carries no subtitles, which is a
        // thing to be told rather than a shorter menu — see SubtitleTracksState.
        val noSubtitles = tracks.size <= 1

        val preferences = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
        val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key)
        bindSubtitleMenu(
            overlay = overlay,
            tracks = tracks.mapIndexed { index, choice ->
                SubtitleMenuEntry(choice.label, selected = index == selectedTrack)
            },
            sizes = SubtitleSize.entries.map {
                SubtitleMenuEntry(it.label, selected = it.key == currentSize)
            },
            onTrack = { index -> selectSubtitleTrack(tracks[index], index) },
            downloads = SubtitleDownloadState(
                available = subtitleDownloadAvailable,
                status = subtitleDownloadStatus,
                entries = subtitleDownloadEntries(noSubtitles),
                expanded = subtitleDownloadExpanded,
            ),
            tracksState = SubtitleTracksState(
                empty = noSubtitles,
                downloadable = subtitleDownloadAvailable,
            ),
            onSize = { index ->
                preferences.edit().putString(SUBTITLE_SIZE_KEY, SubtitleSize.entries[index].key).apply()
                applySubtitleAppearance()
                showSubtitleOverlay(focusSize = index)
            },
            onDownload = ::chooseSubtitleDownload,
        )

        playerView?.hideController()
        val opening = overlay.visibility != View.VISIBLE
        overlay.visibility = View.VISIBLE
        // Only the first open drops up; a redraw after a choice would otherwise slide the
        // menu again under a viewer who is still working their way down it.
        if (opening) {
            overlay.findViewById<View>(R.id.player_subtitle_menu)?.apply {
                alpha = 0f
                translationY = dp(20).toFloat()
                animate().alpha(1f).translationY(0f).setDuration(160L).start()
            }
        }
        val downloadsContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
        val target = when {
            focusTrack != null -> tracksContainer.getChildAt(focusTrack)
            focusSize != null -> sizesContainer.getChildAt(focusSize)
            focusDownload != null -> downloadsContainer?.getChildAt(focusDownload)
            // With no tracks to choose between, the only useful press on this panel is the
            // one that goes looking for some. Landing on "Off" instead would put focus on
            // the state the viewer is already in, below a rule they would have to guess to
            // travel past.
            noSubtitles && subtitleDownloadAvailable -> downloadsContainer?.getChildAt(0)
            else -> tracksContainer.getChildAt(selectedTrack)
        }
        // A redraw can legitimately ask for a row that is gone or disabled — the search row
        // is unfocusable while a query runs. Falling back to the track list keeps the
        // remote alive rather than leaving focus nowhere on somebody's film.
        if (target?.isFocusable == true) target.requestFocus()
        else tracksContainer.getChildAt(selectedTrack)?.requestFocus()
    }

    /**
     * The download section's rows: the one that starts a search, then whatever it found.
     *
     * Derived rather than stored, so every redraw of the menu produces the same section
     * from the same two fields and the list cannot drift from the candidates behind it.
     */
    private fun subtitleDownloadEntries(noSubtitles: Boolean = false): List<SubtitleMenuEntry> = buildList {
        add(
            SubtitleMenuEntry(
                label = getString(
                    when {
                        subtitleCandidates.isNotEmpty() -> R.string.player_subtitle_search_again
                        // The row is the whole point of the panel on a title with none, so
                        // it says what it is for rather than what it does.
                        noSubtitles -> R.string.player_subtitle_none_search
                        else -> R.string.player_subtitle_search
                    },
                ),
                selected = false,
                // Unfocusable while a query is in flight, which is what stops a second
                // press starting a second search.
                enabled = !subtitleRequestInFlight,
                prominent = true,
            ),
        )
        subtitleCandidates.forEach { candidate ->
            add(
                SubtitleMenuEntry(
                    label = candidate.label.ifBlank { candidate.languageLabel },
                    selected = false,
                    enabled = !subtitleRequestInFlight,
                ),
            )
        }
    }

    /** Row 0 always searches; anything below it is a candidate to fetch. */
    private fun chooseSubtitleDownload(index: Int) {
        if (index == 0) {
            searchForSubtitles()
            return
        }
        subtitleCandidates.getOrNull(index - 1)?.let(::downloadSubtitle)
    }

    /**
     * Ask the gateway what subtitles exist for this title.
     *
     * The job is held so a second press cannot start a second search, and it is deliberately
     * not cancelled when the overlay closes — a provider query takes seconds, and throwing
     * away an answer because somebody pressed Back is worse than making them wait. It is
     * cancelled with everything else in [onDestroy].
     */
    private fun searchForSubtitles() {
        if (subtitleRequestInFlight) return
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        subtitleCandidates = emptyList()
        subtitleDownloadStatus = getString(R.string.player_subtitle_searching)
        subtitleDownloadExpanded = true
        subtitleRequestInFlight = true
        subtitleSearchJob = lifecycleScope.launch {
            try {
            // Redrawing from inside the coroutine, before the request, is what puts the
            // waiting line up with the row already disabled: on a slow provider that is the
            // whole difference between "working" and "nothing happened".
            redrawSubtitleOverlay()
                val found = ServiceLocator.repository.searchSubtitles(
                    itemId = id,
                    language = ServiceLocator.settings.current?.subtitleLanguage,
                )
                subtitleCandidates = found.results
                subtitleDownloadStatus = if (found.results.isEmpty()) {
                    found.message.ifBlank { getString(R.string.player_subtitle_search_empty) }
                } else {
                    ""
                }
                Log.i(PLAYBACK_LOG_TAG, "event=subtitle_search item=$id results=${found.results.size}")
            } finally {
                // In a finally so a cancelled search — Back out of the section, or the
                // activity going away — cannot leave every row permanently unpressable.
                subtitleRequestInFlight = false
            }
            redrawSubtitleOverlay(focusDownload = if (subtitleCandidates.isEmpty()) 0 else 1)
        }
    }

    /**
     * Fetch one subtitle and put it on without leaving the film.
     *
     * The gateway has already waited for Emby to notice the new file, so the response
     * carries the item's tracks as they now are and the player only has to swap its media
     * item — the same move the burned-in path makes, and for the same reason: relaunching
     * the activity would cost a viewer their place and their decoder.
     */
    private fun downloadSubtitle(candidate: GatewaySubtitleCandidate) {
        if (subtitleRequestInFlight) return
        val playback = player ?: return
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        subtitleDownloadStatus = getString(
            R.string.player_subtitle_downloading,
            candidate.languageLabel.ifBlank { candidate.language },
        )
        subtitleRequestInFlight = true
        subtitleSearchJob = lifecycleScope.launch {
            redrawSubtitleOverlay()
            val result = try {
                ServiceLocator.repository.downloadSubtitle(id, candidate)
            } finally {
                subtitleRequestInFlight = false
            }
            if (result == null) {
                subtitleDownloadStatus = getString(R.string.player_subtitle_download_failed)
                redrawSubtitleOverlay(focusDownload = 0)
                return@launch
            }
            val position = playback.currentPosition.coerceAtLeast(0L)
            availableSubtitles = result.subtitles
            mediaSourceId = result.mediaSourceId.ifBlank { mediaSourceId }
            playSessionId = result.playSessionId.ifBlank { playSessionId }
            serverSubtitleId = result.selectedSubtitleId
            subtitlePreference = true
            encodedSubtitleId = null
            // The new track only exists in a freshly built media item, so the auto-select
            // pass has to be allowed to run again — otherwise the sidecar is attached and
            // nothing ever turns it on.
            subtitleAutoSelectionAttempted = false
            rememberSubtitleChoice(enabled = true, language = candidate.language)
            pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language }
            playback.setMediaItem(mediaItem(result.url, result.subtitles), position)
            playback.playWhenReady = true
            playback.prepare()
            Log.i(PLAYBACK_LOG_TAG, "event=subtitle_downloaded item=$id language=${candidate.language}")
            subtitleCandidates = emptyList()
            subtitleDownloadStatus = result.message
            subtitleDownloadExpanded = false
            dismissSubtitleOverlayAfterSelection()
        }
    }

    /**
     * Leave the download half and go back to the track list.
     *
     * The results are thrown away with it. They are a snapshot of what providers held a
     * moment ago and the tokens in them go stale; keeping them would mean a viewer coming
     * back an hour later presses a row that quietly fails. A search is one press away.
     */
    private fun collapseSubtitleDownloads() {
        subtitleSearchJob?.cancel()
        subtitleRequestInFlight = false
        subtitleDownloadExpanded = false
        subtitleCandidates = emptyList()
        subtitleDownloadStatus = ""
        redrawSubtitleOverlay()
    }

    /** Redraw the drop-up only if it is still on screen; the state stands either way. */
    private fun redrawSubtitleOverlay(focusDownload: Int? = null) {
        if (subtitleOverlay?.isVisible == true) showSubtitleOverlay(focusDownload = focusDownload)
    }

    /** Applies the track a row stands for, then gets the picker and transport out of the way. */
    @OptIn(UnstableApi::class)
    private fun selectSubtitleTrack(choice: TrackChoice, index: Int) {
        val playback = player ?: return
        choice.encodedSubtitle?.let { subtitle ->
            selectEncodedSubtitle(subtitle)
            return
        }
        val builder = playback.trackSelectionParameters.buildUpon()
            .clearOverridesOfType(C.TRACK_TYPE_TEXT)
            .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, choice.group == null)
        choice.group?.let {
            builder.setOverrideForType(TrackSelectionOverride(it, listOf(choice.index)))
        }
        playback.trackSelectionParameters = builder.build()
        subtitlePreference = choice.group != null
        serverSubtitleId = choice.group?.getFormat(choice.index)?.id.orEmpty()
        rememberSubtitleChoice(
            enabled = choice.group != null,
            language = choice.group?.getFormat(choice.index)?.language,
        )
        encodedSubtitleId = null
        subtitleAutoSelectionAttempted = true
        reportProgress(playback.currentPosition, !playback.isPlaying, "SubtitleTrackChange")
        dismissSubtitleOverlayAfterSelection()
        if (choice.group == null) {
            Toast.makeText(this, R.string.player_subtitle_disabled, Toast.LENGTH_SHORT).show()
        } else {
            showSubtitleConfirmation(choice.label)
        }
    }

    private fun hideSubtitleOverlay() {
        subtitleOverlay?.visibility = View.GONE
        playerView?.showController()
    }

    /** A completed choice returns directly to the programme, without reopening transport. */
    private fun dismissSubtitleOverlayAfterSelection() {
        subtitleOverlay?.visibility = View.GONE
        playerView?.hideController()
        playerView?.requestFocus()
    }

    private fun showSubtitleConfirmation(label: String) {
        Toast.makeText(
            this,
            getString(
                R.string.player_subtitle_loaded,
                label.ifBlank { getString(R.string.player_subtitle_generic) },
            ),
            Toast.LENGTH_SHORT,
        ).show()
    }

    private fun subtitleDisplayLabel(subtitle: PlayableSubtitle): String {
        val name = subtitle.label?.takeIf(String::isNotBlank)
            ?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage }
            ?: "Subtitles"
        val flags = buildList {
            if (subtitle.isForced) add("Forced")
            if (subtitle.isDefault) add("Default")
            if (subtitle.isHearingImpaired) add("SDH")
        }
        return if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}"
    }

    private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) {
        val playback = player ?: return
        val id = itemId?.takeIf(String::isNotBlank) ?: return
        val index = subtitle.id.toIntOrNull() ?: return
        val position = playback.currentPosition.coerceAtLeast(0L)
        dismissSubtitleOverlayAfterSelection()
        reportProgress(position, !playback.isPlaying, "SubtitleTrackChange")
        showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…")
        lifecycleScope.launch {
            runCatching {
                ServiceLocator.repository.selectEncodedSubtitle(
                    playbackSession(id),
                    index,
                    playbackTitle,
                    position,
                )
            }.onSuccess { selected ->
                mediaSourceId = selected.mediaSourceId
                playSessionId = selected.playSessionId
                playMethod = selected.playMethod
                availableSubtitles = selected.subtitles
                encodedSubtitleId = subtitle.id
                subtitlePreference = true
                rememberSubtitleChoice(enabled = true, language = subtitle.language)
                subtitleAutoSelectionAttempted = true
                pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle)
                playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position)
                playback.playWhenReady = true
                playback.prepare()
            }.onFailure {
                showPlaybackError(
                    PlaybackFailure(
                        title = "Couldn’t enable subtitles",
                        detail = "The Emby server could not prepare this image subtitle track.",
                        canAutoRetry = false,
                    ),
                )
            }
        }
    }

    private fun dp(value: Int): Int =
        (value * resources.displayMetrics.density).toInt()

    @OptIn(UnstableApi::class)
    private fun showTrackPicker(trackType: Int) {
        if (trackType == C.TRACK_TYPE_TEXT) {
            showSubtitleOverlay()
            return
        }
        val playback = player ?: return
        val entries = mutableListOf<TrackChoice>()
        playback.currentTracks.groups.forEach { group ->
            if (group.type == trackType) {
                for (index in 0 until group.mediaTrackGroup.length) {
                    if (group.isTrackSupported(index)) {
                        entries += TrackChoice(group.mediaTrackGroup, index, trackLabel(group.mediaTrackGroup, index))
                    }
                }
            }
        }
        val choices = if (trackType == C.TRACK_TYPE_TEXT) listOf(TrackChoice(null, -1, "Off")) + entries else entries
        val selected = choices.indexOfFirst { choice ->
            choice.group != null && playback.currentTracks.groups.any { current ->
                current.mediaTrackGroup == choice.group && current.isTrackSelected(choice.index)
            }
        }.let { if (it >= 0) it else if (trackType == C.TRACK_TYPE_TEXT) 0 else -1 }
        val dialog = AlertDialog.Builder(this)
            .setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles")
            .setSingleChoiceItems(choices.map { it.label }.toTypedArray(), selected) { dialog, which ->
                val choice = choices[which]
                val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType)
                builder.setTrackTypeDisabled(trackType, choice.group == null)
                if (choice.group != null) {
                    builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index)))
                }
                playback.trackSelectionParameters = builder.build()
                if (trackType == C.TRACK_TYPE_AUDIO) {
                    reportProgress(playback.currentPosition, !playback.isPlaying, "AudioTrackChange")
                }
                if (trackType == C.TRACK_TYPE_TEXT) {
                    subtitlePreference = choice.group != null
                    rememberSubtitleChoice(
                        enabled = choice.group != null,
                        language = choice.group?.getFormat(choice.index)?.language,
                    )
                    subtitleAutoSelectionAttempted = true
                }
                dialog.dismiss()
            }
        dialog.show()
    }

    @OptIn(UnstableApi::class)
    private fun trackLabel(group: TrackGroup, index: Int): String {
        val format = group.getFormat(index)
        val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
        val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) }
        val language = format.language?.takeIf { it.isNotBlank() }?.let {
            Locale.forLanguageTag(it).displayLanguage.takeIf(String::isNotBlank)
        }
        val name = meaningfulLabel
            ?: language?.replaceFirstChar { it.uppercase() }
            ?: if (format.channelCount > 0) "${format.channelCount} channel audio" else "Subtitles"
        val traits = buildList {
            if (format.selectionFlags and C.SELECTION_FLAG_FORCED != 0) add("Forced")
            if (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default")
            if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC")
        }
        return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}"
    }

    override fun onStart() {
        super.onStart()
        if (prerollActive) {
            player?.playWhenReady = true
        }
        beginContentWhenReady()
        if (stoppedInBackground && playbackStarted) {
            stoppedInBackground = false
            stopReported = false
            player?.let { reportStarted(it.currentPosition) }
        }
    }

    override fun onStop() {
        // Land any pending skip first, or the position reported to Emby — and so where
        // this title resumes from — is the one the viewer had already skipped past.
        commitSeek()
        player?.let {
            if (playbackStarted && !stopReported) {
                reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
                stopReported = true
                stoppedInBackground = true
                itemId?.takeIf(String::isNotBlank)?.let { id ->
                    PlaybackStopWorker.enqueue(this, playbackSession(id), it.currentPosition)
                }
            }
        }
        super.onStop()
        player?.pause()
    }

    override fun onDestroy() {
        // A launch that never reached a frame still has to close its spans.
        endFirstFrameTrace()
        endLaunchTrace()
        progressJob?.cancel()
        pendingResolveJob?.cancel()
        prerollTimerJob?.cancel()
        prerollScheduleJob?.cancel()
        playbackStartCueJob?.cancel()
        timeRemainingHideJob?.cancel()
        seekCommitJob?.cancel()
        seekHideJob?.cancel()
        seekIndicator?.animate()?.cancel()
        seasonFinaleJob?.cancel()
        seasonFinaleHideJob?.cancel()
        prerollView?.findViewById<View>(R.id.player_preroll_video_host)?.animate()?.cancel()
        castJob?.cancel()
        subtitleSearchJob?.cancel()
        nextUpJob?.cancel()
        creditsSpeedJob?.cancel()
        creditsView?.animate()?.cancel()
        retryJob?.cancel()
        stablePlaybackJob?.cancel()
        playbackIdentityHideJob?.cancel()
        playbackIdentityView?.animate()?.cancel()
        loadingAnimator?.cancel()
        bufferingStartedAtMs?.let { totalBufferingMs += SystemClock.elapsedRealtime() - it }
        Log.i(
            PLAYBACK_LOG_TAG,
            "event=session_end item=${itemId.orEmpty()} rebuffers=$bufferingCount " +
                "bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
        )
        val playback = player
        if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
            stopReported = true
            PlaybackStopWorker.enqueue(
                this,
                playbackSession(itemId!!),
                playback?.currentPosition ?: 0L,
            )
        }
        playerView?.player = null
        playback?.release()
        player = null
        super.onDestroy()
    }

    private fun reportStarted(positionMs: Long) {
        val id = itemId?.takeIf { it.isNotBlank() } ?: return
        lifecycleScope.launch {
            runCatching {
                ServiceLocator.repository.reportPlaybackStarted(playbackSession(id), positionMs)
            }
        }
    }

    private fun reportProgress(positionMs: Long, isPaused: Boolean, eventName: String) {
        val id = itemId?.takeIf { it.isNotBlank() } ?: return
        lifecycleScope.launch {
            val durationMs = player?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L
            val evaluatesAutoFollow = !autoFollowEvaluated &&
                durationMs > 0L && positionMs >= (durationMs + 1L) / 2L
            runCatching {
                ServiceLocator.repository.reportPlaybackProgress(
                    playbackSession(id),
                    positionMs,
                    isPaused,
                    eventName,
                    durationMs.takeUnless { autoFollowEvaluated } ?: 0L,
                )
            }.onSuccess { showTitle ->
                if (evaluatesAutoFollow) autoFollowEvaluated = true
                showTitle?.let {
                    Toast.makeText(
                        this@PlayerActivity,
                        "$it was added to My Shows",
                        Toast.LENGTH_LONG,
                    ).show()
                }
            }
        }
    }

    private fun playbackSession(id: String) = PlaybackSession(
        itemId = id,
        mediaSourceId = mediaSourceId.ifBlank { id },
        playSessionId = playSessionId,
        playMethod = playMethod,
    )

    private fun startProgressReporting() {
        progressJob?.cancel()
        progressJob = lifecycleScope.launch {
            while (isActive) {
                delay(PROGRESS_INTERVAL_MS)
                player?.let {
                    reportProgress(it.currentPosition, isPaused = !it.isPlaying, eventName = "TimeUpdate")
                    updatePlaybackTiming(it)
                }
            }
        }
    }

    private data class TrackChoice(
        val group: TrackGroup?,
        val index: Int,
        val label: String,
        val encodedSubtitle: PlayableSubtitle? = null,
    )

    private enum class SubtitleSize(val key: String, val label: String, val fraction: Float) {
        SMALL("small", "Small", 0.043f),
        MEDIUM("medium", "Medium", SubtitleView.DEFAULT_TEXT_SIZE_FRACTION),
        LARGE("large", "Large", 0.065f),
        EXTRA_LARGE("extra_large", "Extra large", 0.078f),
    }

    private enum class PictureMode(
        val key: String,
        val label: String,
        val shortLabel: String,
    ) {
        AUTO("auto", "Auto · fill classic 4:3 shows", "Auto"),
        ORIGINAL("original", "Original aspect ratio", "Original"),
        FILL("fill", "Fill screen", "Fill"),
    }

    companion object {
        private const val EXTRA_URL = "extra_url"
        private const val EXTRA_ITEM_ID = "extra_item_id"
        private const val EXTRA_TITLE = "extra_title"
        private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
        private const val EXTRA_LOGO_URL = "extra_logo_url"
        private const val EXTRA_OVERVIEW = "extra_overview"
        private const val EXTRA_EPISODE_CODE = "extra_episode_code"
        private const val EXTRA_RUNTIME_MS = "extra_runtime_ms"
        private const val EXTRA_PREROLL_ENABLED = "extra_preroll_enabled"
        private const val EXTRA_PREROLL_DURATION_MS = "extra_preroll_duration_ms"
        private const val EXTRA_POSTER_URL = "extra_poster_url"
        private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms"
        private const val EXTRA_SUBTITLES = "extra_subtitles"
        private const val EXTRA_SUBTITLES_ENABLED = "extra_subtitles_enabled"
        private const val EXTRA_SELECTED_SUBTITLE_ID = "extra_selected_subtitle_id"
        private const val EXTRA_SUBTITLE_DOWNLOAD = "extra_subtitle_download"

        // The three "is it worth asking the gateway" answers. They must travel with the
        // stream on this form of the intent, because it is the form that carries an
        // already-resolved [Playable] and therefore never reaches [adoptPlayable] — which
        // is the only other place they are set. Omitting them left every cold start and
        // every warm-prefetch launch with previews, the skip button and the credits pane
        // silently switched off, since each defaults to false and the default is what a
        // missing extra yields.
        private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
        private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available"
        private const val EXTRA_END_CREDITS = "extra_end_credits_available"
        private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
        private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
        private const val EXTRA_PLAY_METHOD = "extra_play_method"
        private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request"
        private const val PLAYER_PREFERENCES = "player_preferences"
        private const val SUBTITLE_SIZE_KEY = "subtitle_size"
        private const val PICTURE_MODE_KEY = "picture_mode"
        private const val SUBTITLE_BOTTOM_PADDING_FRACTION = 0.095f
        private val playerJson = Json { ignoreUnknownKeys = true }

        /**
         * Starts playback without a stream, letting the player resolve one while it starts.
         * This is the resume path: a viewer picking something up part-way through waits for
         * the negotiation, the activity launch, the file to open at an offset and a decoder
         * to initialise, and only the first of those has to be serial with the rest.
         *
         * A cold start deliberately still takes the other form. Its wait is already spent
         * inside the pre-roll, which cannot begin until there is a stream playing behind it,
         * and whether there is a pre-roll at all is the server's answer to give.
         */
        fun intent(
            context: Context,
            request: PlaybackRequest,
            posterUrl: String? = null,
            requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
        ): Intent = Intent(context, PlayerActivity::class.java).apply {
            putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
            putExtra(EXTRA_ITEM_ID, request.itemId)
            putExtra(EXTRA_TITLE, request.title)
            putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
            request.logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
            request.overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
            request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
            putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L))
            posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) }
            putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
        }

        fun intent(
            context: Context,
            url: String,
            title: String?,
            resumePositionMs: Long = 0L,
        ): Intent = intent(context, itemId = null, url = url, title = title, resumePositionMs = resumePositionMs)

        fun intent(
            context: Context,
            itemId: String?,
            url: String,
            title: String?,
            resumePositionMs: Long = 0L,
            logoUrl: String? = null,
            overview: String? = null,
            episodeCode: String? = null,
            runtimeMs: Long = 0L,
            prerollEnabled: Boolean = true,
            prerollDurationMs: Long = DEFAULT_PREROLL_DURATION_MS,
            posterUrl: String? = null,
            subtitles: List<PlayableSubtitle> = emptyList(),
            subtitlesEnabled: Boolean = true,
            selectedSubtitleId: String = "",
            subtitleDownloadAvailable: Boolean = false,
            trickplayAvailable: Boolean = false,
            skipIntroAvailable: Boolean = false,
            endCreditsAvailable: Boolean = false,
            mediaSourceId: String = "",
            playSessionId: String = "",
            playMethod: String = "DirectPlay",
            requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
        ): Intent =
            Intent(context, PlayerActivity::class.java).apply {
                itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
                putExtra(EXTRA_URL, url)
                putExtra(EXTRA_TITLE, title)
                putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
                logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
                overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
                episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
                putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L))
                putExtra(EXTRA_PREROLL_ENABLED, prerollEnabled)
                putExtra(EXTRA_PREROLL_DURATION_MS, prerollDurationMs)
                posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) }
                if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, playerJson.encodeToString(subtitles))
                putExtra(EXTRA_SUBTITLES_ENABLED, subtitlesEnabled)
                putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
                putExtra(EXTRA_SUBTITLE_DOWNLOAD, subtitleDownloadAvailable)
                putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
                putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
                putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
                putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
                putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
                putExtra(EXTRA_PLAY_METHOD, playMethod)
                putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
            }

        private fun decodeSubtitles(encoded: String?): List<PlayableSubtitle> =
            encoded?.let { runCatching { playerJson.decodeFromString<List<PlayableSubtitle>>(it) }.getOrNull() }
                .orEmpty()

        private fun decodeRequest(encoded: String?): PlaybackRequest? =
            encoded?.let { runCatching { playerJson.decodeFromString<PlaybackRequest>(it) }.getOrNull() }
                ?.takeIf { it.itemId.isNotBlank() }

        private fun mediaItem(url: String, subtitles: List<PlayableSubtitle>): MediaItem {
            val configurations = subtitles.filter {
                it.deliveryMethod.equals("External", true) && it.url.isNotBlank() && it.mimeType.isNotBlank()
            }.map { subtitle ->
                var selectionFlags = 0
                if (subtitle.isDefault) selectionFlags = selectionFlags or C.SELECTION_FLAG_DEFAULT
                if (subtitle.isForced) selectionFlags = selectionFlags or C.SELECTION_FLAG_FORCED
                MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url))
                    .setId(subtitle.id.ifBlank { null })
                    .setMimeType(subtitle.mimeType)
                    .setLanguage(subtitle.language)
                    .setLabel(subtitle.label)
                    .setSelectionFlags(selectionFlags)
                    .setRoleFlags(if (subtitle.isHearingImpaired) C.ROLE_FLAG_CAPTION else 0)
                    .build()
            }
            return MediaItem.Builder()
                .setUri(url)
                .setSubtitleConfigurations(configurations)
                .build()
        }

        private const val PROGRESS_INTERVAL_MS = 10_000L
        private const val PLAYBACK_START_CUE_DELAY_MS = 10_000L
        private const val RESUME_TIME_LEFT_CUE_DELAY_MS = 2_000L
        private const val PLAYBACK_START_CUE_TICK_MS = 250L
        private const val TIME_REMAINING_THRESHOLD_MS = 10L * 60_000L
        private const val TIME_REMAINING_VISIBLE_MS = 6_000L
        private const val TIME_REMAINING_ANIMATION_MS = 240L
        private const val TIME_REMAINING_TRAVEL_DP = 12
        internal const val PLAYBACK_IDENTITY_VISIBLE_MS = 5_000L
        private const val PLAYBACK_IDENTITY_FADE_MS = 250L
        internal const val DEFAULT_PREROLL_DURATION_MS = 6_500L
        private const val MIN_PREROLL_DURATION_MS = 1_000L
        private const val MAX_PREROLL_DURATION_MS = 30_000L
        private const val PREROLL_CALENDAR_SIZE = 8
        private const val PREROLL_CALENDAR_COLUMNS = 4
        private const val PREROLL_CALENDAR_CARD_HEIGHT_DP = 80
        private const val PREROLL_TIMER_TICK_MS = 100L
        private const val PREROLL_FADE_MS = 180L
        private const val PREROLL_TRANSITION_MS = 480L
        private const val MAX_CAST_MEMBERS = 16
        /**
         * Long enough that a run of presses coalesces into one seek, short enough that a
         * single press feels like it did something. It is also why the OSD outlives it:
         * the number on screen has to still be there when the picture catches up.
         */
        private const val SEEK_COMMIT_DELAY_MS = 420L
        private const val SEEK_INDICATOR_VISIBLE_MS = 1_400L

        /**
         * How long a skip may buffer before the loading overlay is allowed to appear
         * after all. A skip over HTTP is a handful of requests — comfortably under this —
         * so in ordinary use the overlay never shows; past it the seek is no longer a
         * skip that is landing, it is a film that has stopped, and saying so is right.
         */
        private const val SEEK_LOADING_GRACE_MS = 6_000L
        private val SEEK_BACK_KEYS = setOf(
            KeyEvent.KEYCODE_DPAD_LEFT,
            KeyEvent.KEYCODE_MEDIA_REWIND,
        )
        private val SEEK_FORWARD_KEYS = setOf(
            KeyEvent.KEYCODE_DPAD_RIGHT,
            KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
        )
        private val CENTRE_KEYS = setOf(
            KeyEvent.KEYCODE_DPAD_CENTER,
            KeyEvent.KEYCODE_ENTER,
            KeyEvent.KEYCODE_NUMPAD_ENTER,
        )
        private val PREROLL_BLOCKED_KEYS = setOf(
            KeyEvent.KEYCODE_DPAD_CENTER,
            KeyEvent.KEYCODE_ENTER,
            KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
            KeyEvent.KEYCODE_MEDIA_PLAY,
            KeyEvent.KEYCODE_MEDIA_PAUSE,
            KeyEvent.KEYCODE_MENU,
            KeyEvent.KEYCODE_SETTINGS,
        )

        /** Show against the still-playing final minute, using media time for determinism. */
        private const val NEXT_UP_LEAD_MS = 60_000L
        private const val NEXT_UP_TICK_MS = 250L
        private const val NEXT_UP_ANIMATION_MS = 260L
        private const val NEXT_UP_VIDEO_SCALE = 0.58f
        private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
        private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f
        private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
        private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360

        /**
         * How often the speed ramp advances. 60 ms over [CREDITS_RAMP_MS] is 25 steps, which
         * is heard as an acceleration rather than as a handful of jumps — the next-up tick's
         * 250 ms would have given six.
         */
        private const val CREDITS_RAMP_TICK_MS = 60L

        /** Floats compared by threshold, so an unchanged speed is never re-sent. */
        private const val CREDITS_SPEED_EPSILON = 0.001f
        /**
         * How often the playhead is compared against the title-sequence markers, and so how
         * often the ring on the button advances. The same rate the next-up countdown reads
         * at, and for the same reason: four times a second is what makes a counter look like
         * it is running rather than stepping. It costs a position read and, on most ticks,
         * nothing else — [SkipIntroCountdownView] refuses to redraw for movement smaller
         * than a degree, which over a two-minute opening is most of them.
         */
        private const val SKIP_INTRO_TICK_MS = 250L

        /**
         * How much of the end of the sequence the offer is held back for. A button that
         * skips the last second of an opening moves the picture imperceptibly, which reads
         * as a broken button rather than as a short skip.
         */
        private const val SKIP_INTRO_TAIL_MS = 2_000L

        /** How long "Intro skipped" stays up after an automatic skip. */
        private const val SKIP_INTRO_NOTICE_MS = 2_400L
        private const val SKIP_INTRO_ANIMATION_MS = 200L
        private const val CONTROLLER_TIMEOUT_MS = 6_000
        private const val NO_TRACE = -1
        private const val FRESH_STREAM_RETRY_ATTEMPT = 2
        private const val STABLE_PLAYBACK_RESET_MS = 30_000L
        private const val PLAYBACK_LOG_TAG = "MembyPlayback"

        private fun playbackStateName(state: Int): String =
            when (state) {
                Player.STATE_IDLE -> "idle"
                Player.STATE_BUFFERING -> "buffering"
                Player.STATE_READY -> "ready"
                Player.STATE_ENDED -> "ended"
                else -> "unknown"
            }

        internal fun formatRemaining(durationMs: Long): String {
            val totalMinutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong()
            return when {
                totalMinutes <= 1L -> "Less than a minute remaining"
                totalMinutes < 60L -> "$totalMinutes min remaining"
                totalMinutes % 60L == 0L -> "${totalMinutes / 60L} hr remaining"
                else -> "${totalMinutes / 60L} hr ${totalMinutes % 60L} min remaining"
            }
        }

        internal fun timeRemainingCueMinutes(remainingMs: Long): Int? {
            if (remainingMs <= 0L || remainingMs >= TIME_REMAINING_THRESHOLD_MS) return null
            return ceil(remainingMs / 60_000.0).toInt().coerceAtLeast(1)
        }

        internal fun playbackStartCueReady(playedMs: Long, resumePositionMs: Long = 0L): Boolean =
            playedMs >= if (resumePositionMs > 0L) {
                RESUME_TIME_LEFT_CUE_DELAY_MS
            } else {
                PLAYBACK_START_CUE_DELAY_MS
            }

        internal fun formatCueDuration(durationMs: Long): String {
            val minutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong().coerceAtLeast(1L)
            return if (minutes == 1L) "1 min" else "$minutes mins"
        }

        internal fun formatPrerollRuntime(durationMs: Long): String? {
            if (durationMs <= 0L || durationMs == C.TIME_UNSET) return null
            val minutes = ceil(durationMs / 60_000.0).toLong().coerceAtLeast(1L)
            val hours = minutes / 60L
            val remainder = minutes % 60L
            return when {
                hours == 0L -> "$minutes mins"
                remainder == 0L -> "${hours}h"
                else -> "${hours}h ${remainder}m"
            }
        }
    }
}

internal fun prerollCanHandOff(
    active: Boolean,
    minimumElapsed: Boolean,
    lifecycleStarted: Boolean,
): Boolean = active && minimumElapsed && lifecycleStarted

/** Preroll intentionally pauses on its first frame, so readiness—not isPlaying—drives time. */
internal fun prerollCountdownAdvances(lifecycleStarted: Boolean, playbackReady: Boolean): Boolean =
    lifecycleStarted && playbackReady

/** A positive position means the viewer is continuing something already started. */
internal fun shouldShowPreroll(resumePositionMs: Long, enabled: Boolean = true): Boolean =
    enabled && resumePositionMs <= 0L

internal enum class PlaybackCompletionAction {
    PLAY_NEXT,
    RETURN_HOME,
}

internal fun playbackCompletionAction(
    hasNextEpisode: Boolean,
    nextUpDismissed: Boolean,
): PlaybackCompletionAction =
    if (hasNextEpisode && !nextUpDismissed) {
        PlaybackCompletionAction.PLAY_NEXT
    } else {
        PlaybackCompletionAction.RETURN_HOME
    }

internal fun shouldZoomVideo(
    modeKey: String,
    width: Int,
    height: Int,
    pixelWidthHeightRatio: Float,
): Boolean {
    if (modeKey == "fill") return true
    if (modeKey != "auto" || width <= 0 || height <= 0) return false
    val displayAspectRatio = width.toFloat() * pixelWidthHeightRatio / height.toFloat()
    return displayAspectRatio in 1.25f..1.50f
}
