package com.ponzischeme89.memby.ui

import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings as AndroidSettings
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.animateColorAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.NightsStay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WbSunny
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationsResponse
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.search.SearchScreen
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.InstallPermission
import com.ponzischeme89.memby.update.RequiredUpdateSignal
import com.ponzischeme89.memby.update.nextUpdateCheckDelay
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
import com.ponzischeme89.memby.update.ServerUpdateService
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Date
import java.util.Calendar

/**
 * The followed show a press stands for, as much of it as the card already knows.
 *
 * The button flips against this rather than against the gateway's answer, which is the
 * whole list decorated with Sonarr's lifecycle for every entry on it. Everything Sonarr
 * would fill in is left at its default — "Not found", "Unknown" — because that is honestly
 * what this television knows for the moment, and the real row replaces it as soon as the
 * save returns. The same promise `scheduleSeriesStub` makes about a detail page.
 */
private fun myShowStub(item: BaseItem): com.ponzischeme89.memby.data.model.MyShow =
    com.ponzischeme89.memby.data.model.MyShow(
        itemId = item.id,
        title = item.name,
        year = item.productionYear,
        imageTag = item.imageTags["Primary"].orEmpty(),
    )

/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */
internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
    if (showHero) {
        (viewportHeight * 0.46f).coerceIn(232.dp, 300.dp)
    } else {
        (viewportHeight * 0.42f).coerceIn(220.dp, 300.dp)
    }

private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>, Any>(
    save = { states ->
        states.flatMap { (key, state) ->
            listOf(key, state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
        }
    },
    restore = { values ->
        mutableMapOf<String, LazyListState>().apply {
            values.chunked(3).forEach { saved ->
                put(
                    saved[0] as String,
                    LazyListState(saved[1] as Int, saved[2] as Int),
                )
            }
        }
    },
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MembyTheme { AppRoot(onCloseSettings = ::finish) }
        }
        PerformanceMonitor.start(this)
    }

    override fun onStart() {
        super.onStart()
        // Also runs when playback returns to Home. A used preroll player is parked while
        // the programme runs so it does not retain a second decoder, then prepared again
        // during the launcher's next idle window.
        PrerollPreloader.start(this)
    }
}

/**
 * How long the launcher will wait for the gateway's onboarding verdict before opening the
 * home screen anyway. Short on purpose: this is the one request left on the signed-in
 * startup path, and everything it gates is already on disk.
 */
private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L
private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L
// Update policy is live operator state, just like maintenance. The verdict is an in-memory
// gateway read, so keeping this close to the maintenance poll means a set that was already
// open sees a newly-required release promptly instead of as much as an hour later.
private const val UPDATE_CHECK_INTERVAL_MS = 30_000L

/**
 * How long to wait before asking again while the gateway has this build retired.
 *
 * There is no attempt budget any more, and that is the point: a television in this state
 * has been signed out and every screen it could otherwise be shown is one the gateway will
 * refuse, so giving up used to mean handing the viewer a sign-in form as the *final*
 * answer. It asks quickly at first, in case the verdict is merely a moment behind the
 * refusal, then settles onto [UPDATE_REQUIRED_BACKOFF_MS] so an unreachable gateway is not
 * polled every few seconds for as long as the set is switched on.
 */
private const val UPDATE_REQUIRED_RETRY_MS = 5_000L
private const val UPDATE_REQUIRED_BACKOFF_MS = 60_000L
private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4

/**
 * How long one row-to-row focus move may hold the D-pad before the launcher assumes
 * nothing is going to answer for it.
 *
 * Comfortably longer than the scroll it waits on plus the destination row's own focus
 * retries, so an ordinary move is never cut short — and short enough that a move nothing
 * completes costs a press rather than the rest of the session's vertical navigation.
 */
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L

@Composable
private fun AppRoot(onCloseSettings: () -> Unit) {
    val repo = ServiceLocator.repository
    val context = LocalContext.current
    // This client intentionally has no token provider and no dependency on the active
    // profile. Updates are an app lifecycle concern, checked before login/session work.
    val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
    var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
    var initialUpdateCheckComplete by remember { mutableStateOf(false) }
    // The refusal as it arrives, before the write of it has come back round through the
    // settings flow. Both halves are needed: this one closes the window in which the
    // sign-out has already landed and the persisted flag has not, and the persisted one
    // (below) is what remembers the refusal across a restart.
    var reportedRequiredUpdate by remember { mutableStateOf<String?>(null) }
    LaunchedEffect(Unit) {
        RequiredUpdateSignal.required.collect {
            // Same rule the persisted copy applies: a refusal this build already satisfies
            // describes an APK that is no longer here.
            if (!requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it)) {
                reportedRequiredUpdate = it
            }
        }
    }
    // Wakes the check loop: a refusal arriving, or the viewer pressing Try again on the
    // retired-build screen. Conflated because the only thing either says is "ask now".
    val updateWake = remember { Channel<Unit>(Channel.CONFLATED) }
    // Held for the length of Memby's opening clip plus its pause, once per process. Not a
    // rememberSaveable: an activity recreated behind the viewer (returning from the TV home
    // screen, a configuration change) is not an app launch, and LaunchIntro carries that
    // across it.
    var introHolding by remember { mutableStateOf(!LaunchIntro.played) }
    LaunchedEffect(Unit) {
        if (!introHolding) return@LaunchedEffect
        // The clip is decoration; the rows are the app. Whatever the player is doing, this
        // is the longest it may ever stand in front of them.
        kotlinx.coroutines.delay(LAUNCH_INTRO_MAX_MS)
        LaunchIntro.played = true
        introHolding = false
    }
    var dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
    var confirmingExit by rememberSaveable { mutableStateOf(false) }
    // SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when
    // Android recreates this activity after the viewer returns from the TV home screen;
    // starting from null needlessly painted "Opening Memby..." while the replayed value
    // made a round trip through a new collector.
    val initialSettings = ServiceLocator.settings.current
    var settings by remember { mutableStateOf(initialSettings) }
    var addingProfile by rememberSaveable { mutableStateOf(false) }
    var startingFirstRun by rememberSaveable { mutableStateOf(false) }
    var installPermissionHandled by rememberSaveable { mutableStateOf(false) }
    // Collected here rather than deeper because it decides which screen AppRoot shows at
    // all. It is one boolean off the poll every TV already makes.
    val installPermissionPrompt by ServiceLocator.maintenance.installPermissionPrompt
        .collectAsStateWithLifecycle()
    var validatedToken by remember { mutableStateOf<String?>(null) }
    var recommendationOnboarding by remember {
        mutableStateOf(
            initialSettings
                ?.takeIf { it.hasCompletedOnboarding || !it.homeCacheJson.isNullOrBlank() }
                ?.let { RecommendationOnboarding(completed = true) },
        )
    }
    var onboardingToken by remember { mutableStateOf<String?>(null) }
    LaunchedEffect(updateService) {
        // Every refusal wakes the loop, including a repeat of a version already handled:
        // the 401 that retires the session and the 426 that refuses the sign-in after it
        // name the same version, and the second is the one a viewer is standing in front
        // of. Filtering those out is what left a television on a form it could not get
        // through until the next hourly check.
        launch { RequiredUpdateSignal.required.collect { updateWake.trySend(Unit) } }
        var firstCheck = true
        // How many checks in a row have been made because this build is retired. Loop
        // state rather than app state: nothing outside this effect decides when to ask.
        var requiredAttempts = 0
        // A launch timeout used to be treated like a successful "nothing to say" verdict,
        // which put the next attempt an hour away. A television's first DNS/TLS connection
        // is often the slowest one it makes, so failed or timed-out checks retry on the same
        // bounded schedule as a retired build until the gateway gives a real answer.
        var failedAttempts = 0
        while (true) {
            // What the gateway has already said about this build, read from disk rather
            // than from this loop's memory — the refusal is commonly one process old.
            val retired = ServiceLocator.settings.current
                ?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
                ?: reportedRequiredUpdate
            val result = if (firstCheck && retired == null) {
                // A disconnected server must not strand an otherwise usable TV at boot.
                // A retired one is not usable, so it waits for the real answer instead of
                // being hurried past it onto a sign-in form.
                withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() }
            } else {
                updateService.check()
            }
            result?.onSuccess { decision ->
                appUpdate = decision?.takeUnless {
                    it.isOptional && dismissedUpdateVersion == it.version
                }
                // Only the gateway may withdraw a refusal, and this is it answering. A
                // verdict that does not require an update is the evidence that whatever
                // retired this build no longer applies — a failed check is not, which is
                // why nothing outside this branch clears the flag.
                if (decision?.isMandatory != true) {
                    reportedRequiredUpdate = null
                    RequiredUpdateSignal.clear()
                    ServiceLocator.settings.clearRequiredUpdate()
                }
            }
            if (firstCheck) {
                initialUpdateCheckComplete = true
                firstCheck = false
            }
            failedAttempts = if (result == null || result.isFailure) {
                failedAttempts + 1
            } else {
                0
            }
            requiredAttempts = if (retired != null && appUpdate == null) {
                requiredAttempts + 1
            } else {
                0
            }
            // Whichever comes first: the next scheduled check, or something asking for one
            // now. While this build is retired and no verdict has arrived, that schedule is
            // seconds rather than an hour — the alternative is a television sitting on a
            // screen it cannot leave, waiting on an answer nobody has asked for.
            val wait = nextUpdateCheckDelay(
                requiredAttempts = requiredAttempts,
                failedAttempts = failedAttempts,
                fastAttempts = UPDATE_REQUIRED_FAST_ATTEMPTS,
                fastRetryMillis = UPDATE_REQUIRED_RETRY_MS,
                backoffMillis = UPDATE_REQUIRED_BACKOFF_MS,
                regularPollMillis = UPDATE_CHECK_INTERVAL_MS,
            )
            withTimeoutOrNull(wait) { updateWake.receive() }
        }
    }
    LaunchedEffect(repo) {
        repo.settingsFlow.collect { settings = it }
    }
    LaunchedEffect(settings?.token, initialUpdateCheckComplete, appUpdate?.version) {
        if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect
        val token = settings?.token?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
        if (validatedToken == token) return@LaunchedEffect
        val valid = repo.validateSession()
        // Record completion only after the suspend call returns. An update verdict can
        // cancel and restart this effect without changing the token; recording it before
        // the call left the replacement effect believing an unfinished validation had run.
        currentCoroutineContext().ensureActive()
        validatedToken = token
        if (!valid) {
            repo.invalidateSession()
        }
    }
    LaunchedEffect(
        settings?.token,
        settings?.userId,
        initialUpdateCheckComplete,
        appUpdate?.version,
    ) {
        if (!initialUpdateCheckComplete || appUpdate != null) return@LaunchedEffect
        val loaded = settings ?: return@LaunchedEffect
        val token = loaded.token?.takeIf { loaded.isSignedIn && it.isNotBlank() }
            ?: run {
                recommendationOnboarding = null
                onboardingToken = null
                return@LaunchedEffect
            }
        // The guard stops a settings emission that did not change the session from asking
        // the gateway again. It must never be able to leave the answer unresolved: this
        // effect is cancelled and restarted whenever the token or user id changes, so a
        // restart that arrived while the previous run was still in flight would otherwise
        // find its own token already recorded, return early, and strand the launcher on
        // the loading screen for good.
        if (onboardingToken == token && recommendationOnboarding != null) return@LaunchedEffect
        onboardingToken = token
        // Existing viewers can open their cached home while this small control-plane
        // request runs. The server still gets the final say: an administrator may have
        // queued a prompt since this TV last saw the profile, or reset a completed setup.
        // A genuinely new profile has no useful home to show and waits for the answer.
        if (!loaded.hasCompletedOnboarding && loaded.homeCacheJson.isNullOrBlank()) {
            recommendationOnboarding = null
        }
        // This request decides which screen opens, so the launcher waits on it — which
        // makes its worst case the app's worst case. Left unbounded that is the gateway
        // client's connect plus read timeout, half a minute of "Opening Memby…" on a TV
        // whose cached rows were ready the whole time. Past this budget the viewer goes
        // to their rows and a profile that genuinely has not onboarded is asked again on
        // the next launch, which is the cheaper mistake by a wide margin.
        val answered = runCatching {
            withTimeoutOrNull(ONBOARDING_CHECK_TIMEOUT_MS) { repo.getRecommendationOnboarding() }
        }.getOrNull()
        // runCatching swallows cancellation too, so say so explicitly: a run abandoned
        // because the session changed must not write its verdict over the one the
        // restarted effect is about to produce.
        currentCoroutineContext().ensureActive()
        // Only a completion the gateway actually reported is remembered. Falling back to
        // "completed" keeps a viewer out of onboarding during an outage, but persisting
        // that guess would silently retire the rating screen for someone who has never
        // seen it, on nothing more than one slow response.
        if (answered?.completed == true) {
            loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) }
        }
        // Do not trap an existing viewer behind onboarding during a gateway outage.
        recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true)
    }

    // Whether this TV has already been told about the build it is running. Keyed
    // on the recorded version and the session, because a fresh install records the current
    // version during setup and a sign-in is what turns "waiting" into "announce it now".
    LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) {
        val loaded = settings ?: return@LaunchedEffect
        val decision = whatsNewDecision(
            installedVersion = BuildConfig.VERSION_NAME,
            seenVersion = loaded.whatsNewSeenVersion,
            isSignedIn = loaded.isSignedIn,
        )
        when (decision) {
            is WhatsNewDecision.Notify -> {
                // Persist the receipt before producing the external effect. If Android
                // recreates the activity immediately after the toast, the new composition
                // must not announce the same build a second time.
                ServiceLocator.settings.markWhatsNewSeen(decision.version)
                currentCoroutineContext().ensureActive()
                Toast.makeText(
                    context,
                    context.getString(R.string.app_updated_to_version, decision.version),
                    Toast.LENGTH_LONG,
                ).show()
            }
            is WhatsNewDecision.MarkSeen ->
                ServiceLocator.settings.markWhatsNewSeen(decision.version)
            WhatsNewDecision.Nothing -> Unit
        }
    }

    Box(Modifier.fillMaxSize().background(MembySurface)) {
        val loaded = settings
        // The gateway has refused this build. Until it answers with a verdict — or with a
        // verdict that no longer requires one — this is the only screen the television may
        // show: its session is gone, and sign-in, profiles and the launcher are all things
        // the server will refuse.
        val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
            ?: reportedRequiredUpdate
        // The three states below that mean "still opening" used to be three separate calls
        // to MembyLoadingScreen, and Compose identifies a composable by where it is called
        // from — so passing between them disposed the screen and built it again. That was
        // only a wasted animation before; now it hands the pre-roll player back and borrows
        // it a moment later, twice, during the one stretch of a launch that is busiest.
        // Hoisted to one call site, the screen and its clip survive the whole cold start.
        val openingQuoteStyle = when {
            // A required update is a service gate, not launcher content. Once the gateway
            // answers, uncover it immediately instead of making the viewer finish the
            // decorative opening clip first. Optional prompts may wait for the intro.
            appUpdate?.isMandatory == true -> null
            // Memby's own clip runs to its end before anything else is drawn. It is the one
            // thing the app owns and it was, in practice, never seen: the launcher uncovered
            // it whenever it happened to be ready, which on a warm start was a fraction of a
            // second. LAUNCH_INTRO_MAX_MS below is the outer bound — the rows are never more
            // than that away, whatever the player does.
            introHolding -> loaded?.welcomeQuoteStyle.orEmpty()
            !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
            appUpdate != null -> null
            // Retired, verdict not here yet. The retired-build screen takes the frame
            // rather than the opening screen: it says what has happened and offers the one
            // thing there is to do about it, where a loading screen with nothing behind it
            // reads as a television that has stopped working.
            retiredVersion != null -> null
            loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
            // Ordered exactly as the screens below are: adding a profile outranks waiting
            // on the onboarding answer, and swapping the two would put the loading screen
            // over a sign-in form somebody is halfway through.
            addingProfile -> null
            loaded.isSignedIn && recommendationOnboarding == null ->
                loaded.welcomeQuoteStyle.orEmpty()
            else -> null
        }
        if (openingQuoteStyle != null) {
            MembyLoadingScreen(
                quoteStyle = openingQuoteStyle,
                onIntroFinished = {
                    LaunchIntro.played = true
                    introHolding = false
                },
            )
        }
        when {
            openingQuoteStyle != null -> Unit
            appUpdate != null -> UpdateScreen(
                update = appUpdate!!,
                onDismiss = {
                    val current = appUpdate ?: return@UpdateScreen
                    if (!current.isMandatory) {
                        dismissedUpdateVersion = current.version
                        appUpdate = null
                    }
                },
            )
            // Ahead of every remaining screen, and of the null-settings case: a television
            // whose build has been retired must not reach sign-in, profiles or the
            // launcher, whatever else is true of it.
            retiredVersion != null -> RetiredBuildScreen(
                requiredVersion = retiredVersion,
                onRetry = { updateWake.trySend(Unit) },
            )
            loaded == null -> Unit
            addingProfile -> SetupScreen(
                onCancel = { addingProfile = false },
                onSignedIn = { addingProfile = false },
            )
            loaded.isSignedIn && recommendationOnboarding?.completed == false &&
                recommendationOnboarding?.prompted == true -> {
                RecommendationOnboardingScreen(
                    onboarding = recommendationOnboarding!!,
                    onSkip = {
                        // Skip is for this visit only. Do not mark or save completion: the
                        // gateway may offer onboarding again on a later launch.
                        recommendationOnboarding = recommendationOnboarding!!.copy(completed = true)
                    },
                    onComplete = { ratings, actors, actresses, directors ->
                        repo.saveRecommendationRatings(ratings, actors, actresses, directors)
                        // Record it locally as well, so this profile's next cold start
                        // skips the gateway check entirely.
                        loaded.userId?.let { ServiceLocator.settings.markOnboardingCompleted(it) }
                        recommendationOnboarding = recommendationOnboarding!!.copy(
                            completed = true,
                            ratings = ratings,
                        )
                    },
                )
            }
            // Pushed by the operator, and only ever seen by a TV that actually lacks the
            // permission — the condition clears itself the moment it is granted. It sits
            // after sign-in rather than before because the household this matters to is the
            // one already using Memby: those sets were installed from Downloader, were never
            // asked, and cannot take an update today.
            loaded.isSignedIn && !installPermissionHandled && installPermissionPrompt &&
                !InstallPermission.granted(LocalContext.current) ->
                InstallPermissionScreen(onContinue = { installPermissionHandled = true })
            loaded.isSignedIn -> {
                BackHandler {
                    if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
                }
                key(loaded.userId, loaded.serverUrl) {
                    HomeScreen(settings = loaded)
                }
                // Snow, bats or blossom for the few days a year a season is on, over the
                // launcher and nowhere else. Not over playback — a film is the one thing
                // nothing may drift across — and not over the settings sheet, which is a
                // page of small text. It is collected here rather than inside HomeScreen so
                // an arriving theme cannot invalidate the rows: this is a sibling node, and
                // the only thing that recomposes when December starts.
                SeasonalDecorations(
                    decoration = ServiceLocator.themeSync.theme
                        .collectAsStateWithLifecycle().value?.decoration.orEmpty(),
                )
            }
            loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
                settings = loaded,
                onAddProfile = { addingProfile = true },
            )
            // Asked before sign-in, on a fresh install only: this is the one moment somebody
            // is certainly in front of the TV and expecting to be asked things. Skippable —
            // see InstallPermissionScreen for why it must never gate a new install.
            startingFirstRun && !installPermissionHandled &&
                !InstallPermission.granted(LocalContext.current) ->
                InstallPermissionScreen(onContinue = { installPermissionHandled = true })
            startingFirstRun -> SetupScreen(
                onCancel = { startingFirstRun = false },
                onSignedIn = { startingFirstRun = false },
            )
            else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
        }
        if (confirmingExit) {
            ExitMembyConfirmation(
                onStay = { confirmingExit = false },
                onExit = onCloseSettings,
            )
        }
    }
}

@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ExitMembyConfirmation(
    onStay: () -> Unit,
    onExit: () -> Unit,
) {
    val stayFocus = remember { FocusRequester() }
    val exitFocus = remember { FocusRequester() }
    BackHandler(onBack = onStay)
    LaunchedEffect(Unit) {
        kotlinx.coroutines.delay(16L)
        runCatching { stayFocus.requestFocus() }
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .zIndex(20f)
            .background(Color.Black.copy(alpha = 0.82f)),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier
                .width(480.dp)
                .background(MembyControlSurface, RoundedCornerShape(18.dp))
                .padding(32.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(18.dp),
        ) {
            Text(
                "Close Memby?",
                color = Color.White,
                fontSize = 26.sp,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "Choose Stay to keep browsing, or close Memby and return to your TV.",
                color = MembyMutedText,
                fontSize = 16.sp,
                textAlign = TextAlign.Center,
            )
            Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
                Button(
                    onClick = onStay,
                    modifier = Modifier
                        .focusRequester(stayFocus)
                        .focusProperties {
                            left = FocusRequester.Cancel
                            right = exitFocus
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Stay in Memby") }
                Button(
                    onClick = onExit,
                    modifier = Modifier
                        .focusRequester(exitFocus)
                        .focusProperties {
                            left = stayFocus
                            right = FocusRequester.Cancel
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Close Memby") }
            }
        }
    }
}

@androidx.media3.common.util.UnstableApi
@Composable
private fun MembyLoadingScreen(
    quoteStyle: String? = null,
    onIntroFinished: () -> Unit = {},
) {
    val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) }
    // Whether Memby's own clip is actually on screen behind this. Set from the player's
    // first rendered frame rather than from having asked it to play, so the logo only gets
    // out of the way once there is something to get out of the way for.
    var prerollVisible by remember { mutableStateOf(false) }
    // The clip has played its length and been held. Everything below it comes back.
    var introDone by remember { mutableStateOf(false) }
    // ...and a moment later the video node goes, which is what hands the player back to the
    // process cache. Removing it on the same frame as the fade would cut to black.
    var introRemoved by remember { mutableStateOf(false) }
    val prerollAlpha by animateFloatAsState(
        targetValue = if (prerollVisible && !introDone) 1f else 0f,
        animationSpec = tween(420),
        label = "cold-start-preroll-alpha",
    )
    // While the clip runs it is the whole screen: the pulsing mark and the welcome line are
    // the *waiting* screen, and printing them over a four-second sting says two things at
    // once. They fade in behind it if the app is still opening when it ends.
    val chromeAlpha by animateFloatAsState(
        targetValue = if (prerollVisible && !introDone) 0f else 1f,
        animationSpec = tween(420),
        label = "cold-start-chrome-alpha",
    )
    LaunchedEffect(introDone) {
        if (!introDone) return@LaunchedEffect
        kotlinx.coroutines.delay(460L)
        introRemoved = true
    }
    // Not keyed on the quote style: the headline says what the app is doing, and rerolling
    // it when the settings flow arrives with a tone would change the line under the viewer
    // mid-launch. Once per appearance of this screen is the intent.
    val headline = remember { randomLoadingHeadline() }
    val transition = rememberInfiniteTransition(label = "cold-start")
    val pulse by transition.animateFloat(
        initialValue = 0.96f,
        targetValue = 1.04f,
        animationSpec = infiniteRepeatable(
            animation = tween(750),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "cold-start-logo-pulse",
    )
    val glow by transition.animateFloat(
        initialValue = 0.35f,
        targetValue = 0.78f,
        animationSpec = infiniteRepeatable(
            animation = tween(750),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "cold-start-glow",
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                Brush.radialGradient(
                    colors = listOf(MembySplashTint, MembySurface),
                    radius = 1_100f,
                ),
            ),
        contentAlignment = Alignment.Center,
    ) {
        if (!introRemoved) {
            LaunchPrerollVideo(
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer { alpha = prerollAlpha },
                onVisible = { prerollVisible = true },
                onFinished = {
                    if (!introDone) {
                        introDone = true
                        onIntroFinished()
                    }
                },
            )
        }
        // The scrim that used to hold this copy legible over the clip is gone with the
        // overlap it existed for: the waiting screen and the clip no longer share the frame,
        // so darkening Memby's own mark by four fifths would be for nobody's benefit.
        Column(
            modifier = Modifier.graphicsLayer { alpha = chromeAlpha },
            verticalArrangement = Arrangement.spacedBy(14.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            // The clip is Memby's own mark moving; the still logo above it would be the
            // same thing said twice, so the whole column gives way while it runs.
            Image(
                painter = painterResource(R.drawable.emby_logo),
                contentDescription = "Memby",
                modifier = Modifier
                    .width(82.dp)
                    .height(68.dp)
                    .graphicsLayer {
                        scaleX = pulse
                        scaleY = pulse
                        alpha = 0.82f + (glow * 0.18f)
                    },
            )
            Text(
                headline,
                color = Color.White.copy(alpha = 0.88f),
                fontSize = 17.sp,
                fontWeight = FontWeight.Medium,
            )
            Text(
                welcomeQuote,
                color = Color.White.copy(alpha = 0.58f),
                fontSize = 13.sp,
                maxLines = 2,
                textAlign = TextAlign.Center,
                modifier = Modifier.width(440.dp),
            )
            Box(
                Modifier
                    .width(92.dp)
                    .height(2.dp)
                    .clip(CircleShape)
                    .background(Color.White.copy(alpha = 0.10f)),
            ) {
                // Drawn, not measured: `fillMaxWidth(glow)` would recompose this screen
                // every frame — during cold start, which is the one moment the device has
                // nothing to spare.
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height(2.dp)
                        .drawBehind {
                            drawRoundRect(
                                color = MembyAccent,
                                size = Size(size.width * glow, size.height),
                                cornerRadius = CornerRadius(size.height / 2f),
                            )
                        },
                )
            }
        }
    }
}

@Composable
internal fun FirstRunScreen(onGetStarted: () -> Unit) {
    val getStartedFocus = remember { FocusRequester() }
    var entered by remember { mutableStateOf(false) }
    val contentAlpha by animateFloatAsState(
        targetValue = if (entered) 1f else 0f,
        animationSpec = tween(420),
        label = "first-run-content-alpha",
    )
    val contentOffset by animateDpAsState(
        targetValue = if (entered) 0.dp else 18.dp,
        animationSpec = tween(420),
        label = "first-run-content-offset",
    )
    val ambient = rememberInfiniteTransition(label = "first-run-ambient")
    val logoScale by ambient.animateFloat(
        initialValue = 0.97f,
        targetValue = 1.04f,
        animationSpec = infiniteRepeatable(
            animation = tween(1_800),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "first-run-logo-scale",
    )
    val haloAlpha by ambient.animateFloat(
        initialValue = 0.10f,
        targetValue = 0.22f,
        animationSpec = infiniteRepeatable(
            animation = tween(1_800),
            repeatMode = RepeatMode.Reverse,
        ),
        label = "first-run-halo",
    )

    LaunchedEffect(Unit) {
        entered = true
        kotlinx.coroutines.delay(180L)
        runCatching { getStartedFocus.requestFocus() }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(
                Brush.linearGradient(
                    colors = listOf(
                        MembySurface,
                        MembySplashTint,
                        MembySurface,
                    ),
                ),
            ),
    ) {
        Box(
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .offset(x = 70.dp)
                .size(560.dp)
                .graphicsLayer { alpha = haloAlpha }
                .background(
                    Brush.radialGradient(
                        colors = listOf(MembyAccent, Color.Transparent),
                    ),
                    CircleShape,
                ),
        )

        Row(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 88.dp, vertical = 64.dp)
                .graphicsLayer { alpha = contentAlpha }
                .offset { IntOffset(x = 0, y = contentOffset.roundToPx()) },
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween,
        ) {
            Column(
                modifier = Modifier.width(590.dp),
                verticalArrangement = Arrangement.spacedBy(18.dp),
            ) {
                Text(
                    "MEMBY",
                    color = MembyAccentBright,
                    fontSize = 14.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 2.sp,
                )
                // Two lines, both of them literal. "Who's watching?" invited a household
                // that most of these TVs do not have, and the line under it listed three
                // nouns to say one thing.
                Text(
                    "Welcome to Memby - Let's get started",
                    color = Color.White,
                    fontSize = 48.sp,
                    fontWeight = FontWeight.Bold,
                )
                Text(
                    "Enter your username & password on the next screen. Ensure to allow permissions for app updates.",
                    color = MembyMutedText,
                    fontSize = 20.sp,
                    lineHeight = 29.sp,
                )
                Spacer(Modifier.height(8.dp))
                Button(
                    onClick = onGetStarted,
                    modifier = Modifier.focusRequester(getStartedFocus),
                ) {
                    Text("Next")
                }
            }

            Box(
                modifier = Modifier.size(330.dp),
                contentAlignment = Alignment.Center,
            ) {
                Box(
                    modifier = Modifier
                        .size(250.dp)
                        .graphicsLayer {
                            scaleX = logoScale
                            scaleY = logoScale
                            alpha = haloAlpha
                        }
                        .background(MembyAccent, CircleShape),
                )
                Image(
                    painter = painterResource(R.drawable.emby_logo),
                    contentDescription = null,
                    modifier = Modifier
                        .width(178.dp)
                        .height(148.dp)
                        .graphicsLayer {
                            scaleX = logoScale
                            scaleY = logoScale
                        },
                )
            }
        }
    }
}

@Composable
private fun SetupScreen(
    onCancel: (() -> Unit)? = null,
    onSignedIn: () -> Unit = {},
) {
    val repo = ServiceLocator.repository
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    val automaticDeviceName = remember(context) {
        ServiceLocator.settings.current?.deviceName
            ?.takeIf { it.isNotBlank() }
            ?: suggestedDeviceName(context)
    }
    var username by rememberSaveable { mutableStateOf("") }
    var password by rememberSaveable { mutableStateOf("") }
    var connecting by remember { mutableStateOf(false) }
    var error by remember { mutableStateOf<String?>(null) }

    val usernameFocus = remember { FocusRequester() }
    val passwordFocus = remember { FocusRequester() }
    val signInFocus = remember { FocusRequester() }
    val backFocus = remember { FocusRequester() }
    LaunchedEffect(Unit) {
        kotlinx.coroutines.delay(100L)
        runCatching { usernameFocus.requestFocus() }
    }
    if (onCancel != null) {
        // The visible Back button is disabled while authentication is running. Hardware
        // Back must obey the same contract and, importantly, must not hide a form whose
        // coroutine can still complete and switch the active viewer afterwards.
        BackHandler { if (!connecting) onCancel() }
    }

    val submit: () -> Unit = {
        if (username.isBlank()) {
            error = "Username is required."
        } else if (!connecting) {
            connecting = true
            error = null
            scope.launch {
                runCatching {
                    repo.authenticate(
                        serverUrl = "",
                        username = username.trim(),
                        password = password,
                        deviceName = automaticDeviceName,
                    )
                }
                    .onSuccess { authenticatedUsername ->
                        val quoteStyle = ServiceLocator.settings.snapshot().welcomeQuoteStyle
                        Toast.makeText(
                            context,
                            loginWelcomeMessage(authenticatedUsername, quoteStyle),
                            Toast.LENGTH_LONG,
                        ).show()
                        onSignedIn()
                    }
                    .onFailure { failure ->
                        error = "Memby couldn't sign in. Check the username and password and try again."
                    }
                connecting = false
            }
        }
    }

    SignInContent(
        username = username,
        password = password,
        onUsernameChange = { username = it; error = null },
        onPasswordChange = { password = it; error = null },
        onSubmit = submit,
        connecting = connecting,
        error = error,
        onBack = onCancel,
        usernameFocus = usernameFocus,
        passwordFocus = passwordFocus,
        signInFocus = signInFocus,
        backFocus = backFocus,
    )
}

internal fun suggestedDeviceName(context: android.content.Context): String {
    val systemName = runCatching {
        AndroidSettings.Global.getString(context.contentResolver, "device_name")
    }.getOrNull()?.trim().orEmpty()
    if (systemName.isNotBlank()) return systemName.take(80)

    val manufacturer = Build.MANUFACTURER
        ?.trim()
        .orEmpty()
        .takeUnless { it.equals("unknown", ignoreCase = true) }
        .orEmpty()
    val model = Build.MODEL
        ?.trim()
        .orEmpty()
        .takeUnless { it.equals("unknown", ignoreCase = true) }
        .orEmpty()
    val inferred = listOf(manufacturer, model)
        .filter { it.isNotBlank() }
        .distinctBy { it.lowercase() }
        .joinToString(" ")
    return inferred.ifBlank { "Living room TV" }.take(80)
}

@Composable
private fun ProfileEntryScreen(
    settings: Settings,
    onAddProfile: () -> Unit,
) {
    val repo = ServiceLocator.repository
    val scope = rememberCoroutineScope()
    var switchingProfileId by remember { mutableStateOf<String?>(null) }
    var removingProfileId by remember { mutableStateOf<String?>(null) }

    ProfileChooser(
        profiles = settings.profiles,
        currentProfileId = null,
        switchingProfileId = switchingProfileId,
        removingProfileId = removingProfileId,
        onSelect = { profile ->
            switchingProfileId = profile.id
            scope.launch {
                runCatching { repo.switchProfile(profile) }
                    .onFailure { switchingProfileId = null }
            }
        },
        onRemove = { profile ->
            removingProfileId = profile.id
            scope.launch {
                runCatching { repo.removeProfile(profile) }
                removingProfileId = null
            }
        },
        onAddProfile = onAddProfile,
        onClose = null,
    )
}

@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ProfileChooser(
    profiles: List<EmbyProfile>,
    currentProfileId: String?,
    switchingProfileId: String?,
    removingProfileId: String?,
    onSelect: (EmbyProfile) -> Unit,
    onRemove: (EmbyProfile) -> Unit,
    onAddProfile: () -> Unit,
    onClose: (() -> Unit)?,
) {
    val addFocus = remember { FocusRequester() }
    val backFocus = remember { FocusRequester() }
    var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
    var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
    val orderedProfiles = remember(profiles, currentProfileId) {
        profiles.sortedByDescending { it.id == currentProfileId }
    }
    val profileIds = orderedProfiles.map(EmbyProfile::id)
    val tileFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
    val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
    LaunchedEffect(profileIds) {
        kotlinx.coroutines.delay(16L)
        val targetIndex = removalReturnIndex?.let {
            profileFocusIndexAfterRemoval(it, orderedProfiles.size)
        } ?: if (orderedProfiles.isEmpty()) null else 0
        removalReturnIndex = null
        if (targetIndex == null) addFocus.requestFocus()
        else tileFocus.getOrNull(targetIndex)?.requestFocus() ?: addFocus.requestFocus()
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MembySurface),
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 72.dp, vertical = 48.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
        ) {
            Image(
                painter = painterResource(R.drawable.emby_logo),
                contentDescription = "Memby",
                modifier = Modifier.width(72.dp).height(60.dp),
            )
            Spacer(Modifier.height(20.dp))
            Text("Who’s watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
            Text(
                when {
                    removingProfileId != null -> "Removing user…"
                    switchingProfileId != null -> "Switching profile…"
                    else -> "Choose a profile to continue"
                },
                color = MembyQuietText,
                fontSize = 17.sp,
                modifier = Modifier.padding(top = 8.dp, bottom = 30.dp),
            )
            LazyRow(
                modifier = Modifier.fillMaxWidth().focusGroup(),
                contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp),
                horizontalArrangement = Arrangement.spacedBy(24.dp),
                verticalAlignment = Alignment.Top,
            ) {
                itemsIndexed(orderedProfiles, key = { _, profile -> profile.id }) { index, profile ->
                    Column(
                        modifier = Modifier.width(154.dp),
                        horizontalAlignment = Alignment.CenterHorizontally,
                        verticalArrangement = Arrangement.spacedBy(8.dp),
                    ) {
                        ProfileTile(
                            name = profile.username,
                            current = profile.id == currentProfileId,
                            enabled = switchingProfileId == null && removingProfileId == null,
                            onClick = { onSelect(profile) },
                            modifier = Modifier
                                .focusRequester(tileFocus[index])
                                .focusProperties {
                                    left = if (index > 0) tileFocus[index - 1] else FocusRequester.Cancel
                                    right = if (index < orderedProfiles.lastIndex) {
                                        tileFocus[index + 1]
                                    } else {
                                        addFocus
                                    }
                                    down = removeFocus[index]
                                },
                        )
                        ProfileDeleteButton(
                            profileName = profile.username,
                            enabled = switchingProfileId == null && removingProfileId == null,
                            onClick = { pendingRemoval = profile },
                            modifier = Modifier
                                .focusRequester(removeFocus[index])
                                .focusProperties {
                                    up = tileFocus[index]
                                    left = if (index > 0) removeFocus[index - 1] else FocusRequester.Cancel
                                    right = if (index < orderedProfiles.lastIndex) {
                                        removeFocus[index + 1]
                                    } else {
                                        FocusRequester.Cancel
                                    }
                                    down = if (onClose != null) backFocus else FocusRequester.Cancel
                                },
                        )
                    }
                }
                item(key = "add-profile") {
                    ProfileTile(
                        name = "Add another user",
                        current = false,
                        enabled = switchingProfileId == null && removingProfileId == null,
                        symbol = "+",
                        onClick = onAddProfile,
                        modifier = Modifier
                            .focusRequester(addFocus)
                            .focusProperties {
                                left = tileFocus.lastOrNull() ?: FocusRequester.Cancel
                                right = FocusRequester.Cancel
                                down = if (onClose != null) backFocus else FocusRequester.Cancel
                            },
                    )
                }
            }
            if (onClose != null) {
                Spacer(Modifier.height(30.dp))
                Button(
                    onClick = onClose,
                    enabled = switchingProfileId == null && removingProfileId == null,
                    modifier = Modifier
                        .focusRequester(backFocus)
                        .focusProperties {
                            up = removeFocus.firstOrNull() ?: addFocus
                        },
                ) { Text("Back to Memby") }
            }
        }
        pendingRemoval?.let { profile ->
            ProfileRemovalConfirmation(
                profile = profile,
                onCancel = { pendingRemoval = null },
                onConfirm = {
                    removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 }
                    pendingRemoval = null
                    onRemove(profile)
                },
            )
        }
    }
}

internal fun profileFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
    if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)

@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun RecommendationOnboardingScreen(
    onboarding: RecommendationOnboarding,
    onComplete: suspend (Map<String, Int>, List<String>, List<String>, List<String>) -> Unit,
    onSkip: () -> Unit = {},
    initialStageIndex: Int = 0,
    previewArtwork: ImageBitmap? = null,
) {
    val repo = ServiceLocator.repository
    val scope = rememberCoroutineScope()
    val ratings = remember(onboarding.ratings) {
        mutableStateMapOf<String, Int>().apply { putAll(onboarding.ratings) }
    }
    val selectedActors = remember { mutableStateMapOf<String, Boolean>() }
    val selectedActresses = remember { mutableStateMapOf<String, Boolean>() }
    val selectedDirectors = remember { mutableStateMapOf<String, Boolean>() }
    val movies = onboarding.movies.ifEmpty { onboarding.items.filter(BaseItem::isMovie) }
    val shows = onboarding.shows.ifEmpty { onboarding.items.filter(BaseItem::isSeries) }
    val stages = remember(movies, shows, onboarding.actors, onboarding.actresses, onboarding.directors) {
        buildList {
            if (movies.isNotEmpty()) add("Movies")
            if (shows.isNotEmpty()) add("TV shows")
            if (onboarding.actors.isNotEmpty()) add("Actors")
            if (onboarding.actresses.isNotEmpty()) add("Actresses")
            if (onboarding.directors.isNotEmpty()) add("Directors")
        }
    }
    var stageIndex by rememberSaveable { mutableStateOf(initialStageIndex.coerceIn(0, (stages.size - 1).coerceAtLeast(0))) }
    var saving by remember { mutableStateOf(false) }
    var error by remember { mutableStateOf<String?>(null) }
    var confirmingSkip by rememberSaveable { mutableStateOf(false) }
    val stage = stages.getOrNull(stageIndex)
    val choiceFocus = remember(stageIndex) { FocusRequester() }
    val backFocus = remember(stageIndex) { FocusRequester() }
    val primaryFocus = remember(stageIndex) { FocusRequester() }

    LaunchedEffect(stageIndex, stage) {
        kotlinx.coroutines.delay(16L)
        runCatching {
            if (stage == null) primaryFocus.requestFocus() else choiceFocus.requestFocus()
        }
    }
    BackHandler {
        when {
            confirmingSkip -> confirmingSkip = false
            stageIndex > 0 -> stageIndex--
            else -> confirmingSkip = true
        }
    }

    val finish: () -> Unit = {
        if (!saving) {
            saving = true
            error = null
            scope.launch {
                runCatching {
                    onComplete(
                        ratings.toMap(),
                        selectedActors.filterValues { it }.keys.toList(),
                        selectedActresses.filterValues { it }.keys.toList(),
                        selectedDirectors.filterValues { it }.keys.toList(),
                    )
                }
                    .onFailure {
                        error = "Memby couldn't save those ratings. Please try again."
                        saving = false
                    }
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .background(
                Brush.radialGradient(
                    listOf(MembySplashTint, MembySurfaceRaised, MembySurface),
                    center = Offset(260f, 180f),
                    radius = 1_500f,
                ),
            )
            .padding(horizontal = 64.dp, vertical = 34.dp),
    ) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                Column(Modifier.weight(1f)) {
                    Text("TUNE YOUR TASTE", color = MembyAccentBright, fontSize = 13.sp, fontWeight = FontWeight.Bold)
                    Text("Make Memby yours", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
                }
                Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
                    stages.forEachIndexed { index, _ ->
                        Box(
                            Modifier
                                .width(if (index == stageIndex) 34.dp else 12.dp)
                                .height(5.dp)
                                .clip(CircleShape)
                                .background(if (index <= stageIndex) MembyAccentBright else MembyControlSurfaceRaised),
                        )
                    }
                }
            }
            Text(
                when (stage) {
                    "Movies" -> "Choose movies you love"
                    "TV shows" -> "Choose shows you would recommend"
                    "Actors" -> "Pick actors you enjoy watching"
                    "Actresses" -> "Pick actresses you enjoy watching"
                    "Directors" -> "Pick directors whose work you seek out"
                    else -> "A few quick picks"
                },
                color = Color.White,
                fontSize = 25.sp,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "Select as many as you like, or skip this step. Every choice gives your recommendations a stronger starting point.",
                color = MembyMutedText, fontSize = 16.sp,
            )
            Spacer(Modifier.height(8.dp))
            if (stage == null) {
                Spacer(Modifier.weight(1f))
                Text("Your library is still being prepared. You can start watching now and Memby will learn as you go.", color = MembyMutedText)
                Spacer(Modifier.weight(1f))
            } else {
                when (stage) {
                    "Movies" -> OnboardingTitleRow(
                        movies, ratings, choiceFocus, primaryFocus, previewArtwork,
                    )
                    "TV shows" -> OnboardingTitleRow(
                        shows, ratings, choiceFocus, primaryFocus, previewArtwork,
                    )
                    "Actors" -> OnboardingPeopleRow(
                        onboarding.actors, selectedActors, choiceFocus, primaryFocus, previewArtwork,
                    )
                    "Actresses" -> OnboardingPeopleRow(
                        onboarding.actresses, selectedActresses, choiceFocus, primaryFocus, previewArtwork,
                    )
                    "Directors" -> OnboardingPeopleRow(
                        onboarding.directors, selectedDirectors, choiceFocus, primaryFocus, previewArtwork,
                    )
                }
            }
            Spacer(Modifier.weight(1f))
            Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
                Text(
                    "${ratings.count { it.value >= 4 } + selectedActors.count { it.value } + selectedActresses.count { it.value } + selectedDirectors.count { it.value }} selected",
                    color = MembyQuietText, fontSize = 14.sp, modifier = Modifier.weight(1f),
                )
                error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) }
                if (stageIndex > 0) {
                    Button(
                        onClick = { stageIndex-- },
                        enabled = !saving,
                        modifier = Modifier
                            .focusRequester(backFocus)
                            .focusProperties {
                                up = if (stage == null) FocusRequester.Cancel else choiceFocus
                                right = primaryFocus
                            },
                    ) { Text("Back") }
                }
                Spacer(Modifier.width(10.dp))
                if (stageIndex < stages.lastIndex) {
                    Button(
                        onClick = { stageIndex++ },
                        enabled = !saving,
                        modifier = Modifier
                            .focusRequester(primaryFocus)
                            .focusProperties {
                                up = if (stage == null) FocusRequester.Cancel else choiceFocus
                                left = if (stageIndex > 0) backFocus else FocusRequester.Cancel
                            },
                    ) { Text("Next") }
                } else {
                    Button(
                        onClick = finish,
                        enabled = !saving,
                        modifier = Modifier
                            .focusRequester(primaryFocus)
                            .focusProperties {
                                up = if (stage == null) FocusRequester.Cancel else choiceFocus
                                left = if (stageIndex > 0) backFocus else FocusRequester.Cancel
                            },
                    ) { Text(if (saving) "Saving…" else "Start watching") }
                }
            }
        }
        if (confirmingSkip) {
            OnboardingSkipConfirmation(
                onKeepChoosing = { confirmingSkip = false },
                onSkip = onSkip,
            )
        }
    }
}

@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun OnboardingSkipConfirmation(
    onKeepChoosing: () -> Unit,
    onSkip: () -> Unit,
) {
    val keepFocus = remember { FocusRequester() }
    val skipFocus = remember { FocusRequester() }
    LaunchedEffect(Unit) {
        kotlinx.coroutines.delay(16L)
        runCatching { keepFocus.requestFocus() }
    }
    Box(
        Modifier.fillMaxSize().zIndex(20f).background(Color.Black.copy(alpha = 0.82f)),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier
                .width(480.dp)
                .background(MembyControlSurface, RoundedCornerShape(18.dp))
                .padding(32.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(18.dp),
        ) {
            Text(
                "Skip taste setup for now?",
                color = Color.White,
                fontSize = 24.sp,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "You can start watching now. Memby may offer these choices again later.",
                color = MembyMutedText,
                fontSize = 16.sp,
                textAlign = TextAlign.Center,
            )
            Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
                Button(
                    onClick = onKeepChoosing,
                    modifier = Modifier
                        .focusRequester(keepFocus)
                        .focusProperties {
                            left = FocusRequester.Cancel
                            right = skipFocus
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Keep choosing") }
                Button(
                    onClick = onSkip,
                    modifier = Modifier
                        .focusRequester(skipFocus)
                        .focusProperties {
                            left = keepFocus
                            right = FocusRequester.Cancel
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Skip for now") }
            }
        }
    }
}

@Composable
private fun OnboardingTitleRow(
    items: List<BaseItem>,
    ratings: MutableMap<String, Int>,
    entryFocus: FocusRequester,
    footerFocus: FocusRequester,
    previewArtwork: ImageBitmap? = null,
) {
    val repo = ServiceLocator.repository
    val distinctItems = remember(items) { items.distinctItems() }
    LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
        itemsIndexed(distinctItems, key = { _, item -> item.id }) { index, item ->
            val selected = ratings[item.id] == 5
            FocusScaleContainer(
                onFocused = {},
                onClick = { if (selected) ratings.remove(item.id) else ratings[item.id] = 5 },
                contentDescription = "${item.name}${if (selected) ", selected" else ""}",
                modifier = Modifier
                    .width(148.dp)
                    .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier)
                    .focusProperties { down = footerFocus },
            ) { focused ->
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Box(
                        Modifier
                            .width(148.dp).height(222.dp)
                            .clip(RoundedCornerShape(12.dp))
                            .background(MembyControlSurface)
                            .border(if (focused || selected) 3.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, RoundedCornerShape(12.dp)),
                    ) {
                        if (previewArtwork != null) {
                            Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
                        } else {
                            AsyncImage(model = repo.primaryUrl(item, 320), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
                        }
                        if (selected) Text("✓  PICKED", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(MembyAccent.copy(alpha = 0.91f), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
                    }
                    Text(item.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
                    Text(listOfNotNull(item.productionYear?.toString(), item.genres.firstOrNull()).joinToString(" · "), color = MembyQuietText, fontSize = 11.sp, maxLines = 1)
                }
            }
        }
    }
}

@Composable
private fun OnboardingPeopleRow(
    people: List<RecommendationPerson>,
    selectedPeople: MutableMap<String, Boolean>,
    entryFocus: FocusRequester,
    footerFocus: FocusRequester,
    previewArtwork: ImageBitmap? = null,
) {
    val repo = ServiceLocator.repository
    // Keyed by name because that is also what the selection map is keyed by — which means
    // two people sharing one would be one selection *and* a duplicate LazyRow key, and the
    // second of those is a crash on the first screen a new television ever draws.
    val distinctPeople = remember(people) { people.distinctForKeys(RecommendationPerson::name) }
    LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
        itemsIndexed(distinctPeople, key = { _, person -> person.name }) { index, person ->
            val selected = selectedPeople[person.name] == true
            FocusScaleContainer(
                onFocused = {},
                onClick = { selectedPeople[person.name] = !selected },
                contentDescription = "${person.name}${if (selected) ", selected" else ""}",
                modifier = Modifier
                    .width(148.dp)
                    .then(if (index == 0) Modifier.focusRequester(entryFocus) else Modifier)
                    .focusProperties { down = footerFocus },
            ) { focused ->
                Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
                    Box(
                        Modifier.size(148.dp).clip(CircleShape).background(MembyAccentMuted)
                            .border(if (focused || selected) 4.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, CircleShape),
                        contentAlignment = Alignment.Center,
                    ) {
                        if (previewArtwork != null) {
                            Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
                        } else if (person.id.isNotBlank() && person.imageTag.isNotBlank()) {
                            AsyncImage(model = repo.posterUrl(person.id, person.imageTag, 280), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
                        } else {
                            Text(person.name.take(1).uppercase(), color = MembyOnSurface, fontSize = 46.sp, fontWeight = FontWeight.Light)
                        }
                        if (selected) Text("✓", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
                    }
                    Text(person.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
                }
            }
        }
    }
}

/** Stable fixtures for screenshot reviews of the recommendation taste journey. */
@Composable
internal fun RecommendationOnboardingPreview(
    initialStageIndex: Int = 0,
    previewArtwork: ImageBitmap? = null,
    previewFocusRequester: FocusRequester? = null,
) {
    val localPreviewFocus = remember { FocusRequester() }
    val previewFocus = previewFocusRequester ?: localPreviewFocus
    LaunchedEffect(Unit) { runCatching { previewFocus.requestFocus() } }
    val titles = listOf(
        BaseItem(id = "arrival", name = "Arrival", type = "Movie", productionYear = 2016, genres = listOf("Science Fiction")),
        BaseItem(id = "parasite", name = "Parasite", type = "Movie", productionYear = 2019, genres = listOf("Thriller")),
        BaseItem(id = "spirited-away", name = "Spirited Away", type = "Movie", productionYear = 2001, genres = listOf("Animation")),
        BaseItem(id = "moonlight", name = "Moonlight", type = "Movie", productionYear = 2016, genres = listOf("Drama")),
        BaseItem(id = "mad-max", name = "Mad Max: Fury Road", type = "Movie", productionYear = 2015, genres = listOf("Action")),
        BaseItem(id = "hunt-wilderpeople", name = "Hunt for the Wilderpeople", type = "Movie", productionYear = 2016, genres = listOf("Comedy")),
    )
    val shows = listOf(
        BaseItem(id = "severance", name = "Severance", type = "Series", productionYear = 2022, genres = listOf("Drama")),
        BaseItem(id = "atlanta", name = "Atlanta", type = "Series", productionYear = 2016, genres = listOf("Comedy")),
    )
    val people = listOf(
        RecommendationPerson(id = "p1", name = "Mahershala Ali", imageTag = "portrait"),
        RecommendationPerson(id = "p2", name = "Dev Patel", imageTag = "portrait"),
        RecommendationPerson(id = "p3", name = "Steven Yeun", imageTag = "portrait"),
        RecommendationPerson(id = "p4", name = "Oscar Isaac", imageTag = "portrait"),
        RecommendationPerson(id = "p5", name = "Daniel Kaluuya", imageTag = "portrait"),
        RecommendationPerson(id = "p6", name = "Taika Waititi", imageTag = "portrait"),
    )
    Box(Modifier.fillMaxSize().focusRequester(previewFocus).focusable()) {
        RecommendationOnboardingScreen(
            onboarding = RecommendationOnboarding(
                ratings = mapOf("arrival" to 5, "moonlight" to 5),
                movies = titles,
                shows = shows,
                actors = people,
                actresses = people.mapIndexed { index, person -> person.copy(id = "a$index", name = listOf("Michelle Yeoh", "Viola Davis", "Emma Stone", "Ayo Edebiri", "Melanie Lynskey", "Sandra Oh")[index]) },
                directors = people.mapIndexed { index, person -> person.copy(id = "d$index", name = listOf("Denis Villeneuve", "Bong Joon-ho", "Greta Gerwig", "Jordan Peele", "Chloé Zhao", "Jane Campion")[index]) },
            ),
            onComplete = { _, _, _, _ -> },
            initialStageIndex = initialStageIndex,
            previewArtwork = previewArtwork,
        )
    }
}

@Composable
private fun ProfileDeleteButton(
    profileName: String,
    enabled: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    var focused by remember { mutableStateOf(false) }
    Row(
        modifier = modifier
            .width(124.dp)
            .height(34.dp)
            .zIndex(2f)
            .clip(RoundedCornerShape(9.dp))
            .background(
                when {
                    !enabled -> MembyControlSurfaceRaised
                    focused -> Color(0xFFE34B4B)
                    else -> MembyControlSurface.copy(alpha = 0.90f)
                },
            )
            .border(
                width = if (focused) 2.dp else 1.dp,
                color = if (focused) Color.White else Color.White.copy(alpha = 0.55f),
                shape = RoundedCornerShape(9.dp),
            )
            .onFocusChanged { focused = it.isFocused }
            .clickable(enabled = enabled, onClick = onClick)
            .semantics { contentDescription = "Remove $profileName" },
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center,
    ) {
        Text(
            text = "Remove user",
            color = if (enabled) Color.White else MembyDisabledText,
            fontSize = 12.sp,
            fontWeight = FontWeight.SemiBold,
        )
    }
}

@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ProfileRemovalConfirmation(
    profile: EmbyProfile,
    onCancel: () -> Unit,
    onConfirm: () -> Unit,
) {
    val cancelFocus = remember { FocusRequester() }
    val removeFocus = remember { FocusRequester() }
    BackHandler(onBack = onCancel)
    LaunchedEffect(profile.id) { cancelFocus.requestFocus() }
    Box(
        Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.78f)),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            modifier = Modifier
                .width(460.dp)
                .background(MembyControlSurface, RoundedCornerShape(18.dp))
                .padding(32.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(18.dp),
        ) {
            Text(
                "Remove ${profile.username} from this TV?",
                color = Color.White,
                fontSize = 24.sp,
                fontWeight = FontWeight.SemiBold,
            )
            Text(
                "Their saved sign-in will be forgotten on this device.",
                color = MembyMutedText,
                fontSize = 16.sp,
            )
            Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
                Button(
                    onClick = onCancel,
                    modifier = Modifier
                        .focusRequester(cancelFocus)
                        .focusProperties {
                            left = FocusRequester.Cancel
                            right = removeFocus
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Cancel") }
                Button(
                    onClick = onConfirm,
                    modifier = Modifier
                        .focusRequester(removeFocus)
                        .focusProperties {
                            left = cancelFocus
                            right = FocusRequester.Cancel
                            up = FocusRequester.Cancel
                            down = FocusRequester.Cancel
                        },
                ) { Text("Remove user") }
            }
        }
    }
}

@Composable
private fun ProfileTile(
    name: String,
    current: Boolean,
    enabled: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    symbol: String = name.trim().firstOrNull()?.uppercase() ?: "?",
) {
    FocusScaleContainer(
        onFocused = {},
        onClick = { if (enabled) onClick() },
        contentDescription = if (current) "$name, current profile" else name,
        modifier = modifier.width(154.dp),
    ) { focused ->
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Box(
                modifier = Modifier
                    .size(112.dp)
                    .clip(CircleShape)
                    .background(if (focused) MembyAccent else MembyControlSurfaceRaised)
                    .border(
                        width = if (current) 4.dp else if (focused) 3.dp else 1.dp,
                        color = if (current) MembyAccent else if (focused) Color.White else MembyDisabledText,
                        shape = CircleShape,
                    ),
                contentAlignment = Alignment.Center,
            ) {
                Text(symbol, color = Color.White, fontSize = 38.sp, fontWeight = FontWeight.Bold)
            }
            Text(
                name,
                color = if (focused) Color.White else MembyMutedText,
                fontSize = 16.sp,
                fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium,
                maxLines = 2,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.padding(top = 12.dp),
            )
            if (current) {
                Text("Current", color = MembyAccentBright, fontSize = 13.sp, modifier = Modifier.padding(top = 3.dp))
            }
        }
    }
}

@Composable
private fun HomeScreen(
    settings: Settings,
) {
    val repo = ServiceLocator.repository
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
    val factory = remember(repo) { HomeViewModelFactory(repo) }
    // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and
    // in-flight requests. The Activity's default ViewModelStore outlives a Compose key,
    // so using it here leaks the previous profile's state across a profile switch.
    val profileViewModelOwner = remember(
        settings.activeProfileId,
        settings.serverUrl,
        settings.userId,
    ) { ProfileViewModelStoreOwner() }
    DisposableEffect(profileViewModelOwner) {
        onDispose { profileViewModelOwner.clear() }
    }
    val homeViewModel: HomeViewModel = viewModel(
        viewModelStoreOwner = profileViewModelOwner,
        factory = factory,
    )
    // Three narrow projections rather than the whole HomeUiState: this function is large
    // enough that subscribing to every field made an arriving update verdict or a banner
    // change recompose the launcher and rebuild every row. See HomeViewModel.content.
    val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
    val homeStatus by homeViewModel.status.collectAsStateWithLifecycle()
    val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
    val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
    val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
    val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
    val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
    val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
    val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
    // Only whether there is one, not its countdown — that is collected inside the banner
    // so a ticking second never reaches the launcher. This is read here purely to decide
    // which of the two top bars gets the strip.
    val embyOutage by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle()

    var showSettings by remember { mutableStateOf(false) }
    var showProfiles by remember { mutableStateOf(false) }
    // Reached only from the manage-users page, which is itself two presses in behind the
    // user picker — so the "+" is where somebody looking to add a viewer is already
    // standing, and it is nowhere near the launcher.
    var addingProfile by remember { mutableStateOf(false) }
    var userSwitcherVisible by remember { mutableStateOf(false) }
    var switchingProfileId by remember { mutableStateOf<String?>(null) }
    var removingProfileId by remember { mutableStateOf<String?>(null) }
    var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
    var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
    var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
    var navigationExpanded by rememberSaveable { mutableStateOf(false) }
    var restoreRailAfterSettings by remember { mutableStateOf(false) }
    var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
    // Detail pages can now open one another through the related rail. Back walks the trail
    // home one page at a time; without this it would drop three levels to the launcher.
    var detailsTrail by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
    var restoreDetailPosition by remember { mutableStateOf(false) }
    // Belongs to the *route*, not to the show: set only when a page is opened from the
    // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the
    // same series reached from Favourites or a search never claims a schedule.
    var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
    var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
    var quickMenuRowId by remember { mutableStateOf<String?>(null) }
    var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
    var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
    var myShowsLoading by remember(settings.userId) { mutableStateOf(true) }
    var myShowsError by remember(settings.userId) { mutableStateOf<String?>(null) }
    var myShowsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
    var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
    var myShowReturnItemId by remember { mutableStateOf<String?>(null) }
    var removingMyShow by remember { mutableStateOf(false) }
    var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) }
    var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
    var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
    var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
    var showNotifications by remember { mutableStateOf(false) }
    // Two different things: [launchingItem] is the gate that stops a second Play press
    // stacking a second player, and stays shut until one comes back. [resolvingItem] is
    // the loading screen, and belongs only to a launch that is waiting on the server.
    var launchingItem by remember { mutableStateOf<BaseItem?>(null) }
    var resolvingItem by remember { mutableStateOf<BaseItem?>(null) }
    var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
    var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
    var recentSearches by remember { mutableStateOf<List<String>>(emptyList()) }
    var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
    // Destination and row list states live above the conditional content branches.
    // Opening Search/Settings/details therefore never creates a new list at position 0.
    val verticalStates = rememberSaveable(saver = LazyListStateMapSaver) {
        mutableMapOf<String, LazyListState>()
    }
    val horizontalStates = rememberSaveable(saver = LazyListStateMapSaver) {
        mutableMapOf<String, LazyListState>()
    }
    val rememberedRowFocus = remember {
        mutableMapOf<BrowseDestination, MutableMap<String, Int>>()
    }
    val destinationFocus = remember {
        mutableMapOf<BrowseDestination, Pair<String, String>>()
    }
    var showForYouNudge by rememberSaveable(settings.activeProfileId) {
        mutableStateOf(!settings.hasOpenedForYou)
    }
    // Each destination owns a stable requester. Moving one requester between rail items
    // can leave a key event pointing at the previously selected item for a frame.
    val navigationFocusRequesters = remember {
        BrowseDestination.entries.associateWith { FocusRequester() }
    }
    var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) }
    val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination)
    val contentFocusRequester = remember { FocusRequester() }
    val cardReturnFocusRequester = remember { FocusRequester() }
    val myShowReturnFocusRequester = remember { FocusRequester() }
    // Down out of the hero, stated rather than left to Compose's spatial search. The
    // featured card is wide, so its centre sits nearer the second card of the shelf below
    // than the first, and the search obligingly skipped past the thing somebody had just
    // been reading about in Continue Watching.
    val heroRowEntryFocusRequester = remember { FocusRequester() }
    // Feature flags can remove the node that currently owns focus. Move the route first,
    // wait for its replacement to compose, then establish an explicit remote target.
    LaunchedEffect(tvCalendarEnabled) {
        if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
            selectedDestination = BrowseDestination.HOME
            railFocusDestination = BrowseDestination.HOME
            kotlinx.coroutines.delay(16L)
            requestFirstAvailableFocus(
                contentFocusRequester,
                navigationFocusRequesters.getValue(BrowseDestination.HOME),
            )
        }
    }
    LaunchedEffect(genreBrowserEnabled) {
        if (!genreBrowserEnabled && genreBrowseItemType != null) {
            genreBrowseItemType = null
            genreBrowseInitialCategoryId = null
            kotlinx.coroutines.delay(16L)
            requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
        }
    }
    var initialFocusRequested by remember { mutableStateOf(false) }
    // Incremented when a rail selection needs to restore a card in the lazy row list.
    // The list owns the scroll state, so it also owns the actual restoration below.
    var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
    val playbackLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult(),
    ) {
        homeViewModel.trackJourney(
            category = "playback", action = "stop", screen = "player",
            feature = "playback", target = selectedDestination.name.lowercase(),
        )
        // PlayerActivity has finished and this activity owns the window again. Compose
        // needs one frame to reattach the saved card's focus node before it can receive
        // focus, especially when playback progress refreshed the row behind the player.
        // Clearing the launch gate here rather than when the intent was fired covers both
        // routes into the player, including the one that hands over without waiting.
        launchingItem = null
        scope.launch {
			myShowsLoading = true
			notificationsLoading = true
			runCatching { repo.getMyShows() }
				.onSuccess { myShows = it; myShowsError = null }
				.onFailure {
                    if (it is kotlinx.coroutines.CancellationException) throw it
                    myShowsError = friendlyEmbyError(it)
                }
			runCatching { repo.getNotifications() }
				.onSuccess { notificationState = it; notificationsError = null }
				.onFailure {
                    if (it is kotlinx.coroutines.CancellationException) throw it
                    notificationsError = friendlyEmbyError(it)
                }
			myShowsLoading = false
			notificationsLoading = false
            kotlinx.coroutines.delay(32L)
            if (returnRowId != null && returnItemId != null) {
                requestFirstAvailableFocus(
                    cardReturnFocusRequester,
                    contentFocusRequester,
                    navigationFocusRequester,
                )
            } else {
                requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
            }
        }
    }

    LaunchedEffect(settings.userId) {
        myShowsLoading = true
        notificationsLoading = true
        runCatching { repo.getMyShows() }
            .onSuccess { myShows = it; myShowsError = null }
            .onFailure {
                if (it is kotlinx.coroutines.CancellationException) throw it
                myShowsError = friendlyEmbyError(it)
            }
        runCatching { repo.getNotifications() }
            .onSuccess { notificationState = it; notificationsError = null }
            .onFailure {
                if (it is kotlinx.coroutines.CancellationException) throw it
                notificationsError = friendlyEmbyError(it)
            }
        myShowsLoading = false
        notificationsLoading = false
    }

    LaunchedEffect(liveMaintenance) {
        if (liveMaintenance != null) {
            // Maintenance is an app-wide interruption, not another overlay in the stack.
            showSettings = false
            showProfiles = false
            userSwitcherVisible = false
            showNotifications = false
            detailsItem = null
            detailsAiringNotice = null
            quickMenuItem = null
            quickMenuRowId = null
        } else if (homeStatus.maintenanceMessage != null) {
            // A successful live status is authoritative; refresh content immediately
            // instead of waiting for the maintenance screen's slower retry timer.
            homeViewModel.refreshAll()
        }
    }
    // The job resolving a stream, so the overlay it raises can be called off. Without it,
    // a Play press against a server that has stopped answering holds a full-screen loading
    // overlay for the length of the HTTP timeout with no way to leave it.
    var resolveJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
    val abandonLaunch: () -> Unit = {
        resolveJob?.cancel()
        resolveJob = null
        resolvingItem = null
        launchingItem = null
    }
    val playItem: (BaseItem) -> Unit = playItem@{ item ->
        if (launchingItem != null || !item.membyPlayable) return@playItem
        homeViewModel.trackJourney(
            category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
            feature = "playback", source = returnRowId.orEmpty(), target = "player",
            itemId = item.id, itemType = item.type,
        )
        homeViewModel.flushAnalytics()
        launchingItem = item
        val playbackRequestedAtMs = SystemClock.elapsedRealtime()
        // Resuming: open the player now and let it resolve the stream while it starts.
        // Waiting here would spend the negotiation on a still launcher and only then begin
        // the activity, the layout and the decoder, none of which needed the answer. A cold
        // start still resolves first — the pre-roll it opens with needs a stream to run
        // behind it, and whether there is one to show is part of the same answer.
        // Everything up to the hand-over runs inside a catch that reopens the gate. The
        // gate is otherwise cleared only by the player coming back, so anything that
        // throws before one is started — a malformed cached item, an activity result
        // registry that has already been torn down — would leave Play dead for the rest
        // of the session with nothing on screen to say why.
        val prepared = runCatching {
            val request = repo.playbackRequest(item)
            request to repo.readyPlayableForLaunch(request)
        }.getOrElse {
            Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
            launchingItem = null
            return@playItem
        }
        val request = prepared.first
        val ready = prepared.second
        if (ready == null && request.resumePositionMs > 0L) {
            val launched = runCatching {
                playbackLauncher.launch(
                    PlayerActivity.intent(
                        context = context,
                        request = request,
                        posterUrl = repo.primaryUrl(item, maxWidth = 500),
                        requestStartedAtMs = playbackRequestedAtMs,
                    ),
                )
            }
            if (launched.isFailure) {
                Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
                launchingItem = null
            }
            return@playItem
        }
        // Only the route that waits on the server shows the launcher's own loading screen.
        // The route that hands over immediately would only be showing it behind the player.
        resolvingItem = item
        resolveJob = scope.launch {
            try {
                runCatching {
                    val playable = ready ?: repo.resolvePlayableForLaunch(item)
                    // A resolution that came back without a stream is a failure with a
                    // success's shape. Handed on, PlayerActivity finds no URL and no
                    // request to resolve one from, closes itself in onCreate, and the
                    // viewer sees the screen flicker and nothing else — the one playback
                    // failure that arrives with no explanation at all.
                    check(playable.url.isNotBlank()) { "resolved playable has no stream" }
                    playable
                }
                    .onSuccess { playable ->
                        val launched = runCatching {
                            playbackLauncher.launch(
                                PlayerActivity.intent(
                                context = context,
                                itemId = playable.itemId,
                                url = playable.url,
                                title = playable.title,
                                resumePositionMs = playable.resumePositionMs,
                                logoUrl = playable.logoUrl,
                                overview = playable.overview ?: item.overview,
                                episodeCode = playable.episodeCode,
                                runtimeMs = playable.runtimeMs,
                                prerollEnabled = playable.prerollEnabled,
                                prerollDurationMs = playable.prerollDurationMs,
                                posterUrl = repo.primaryUrl(item, maxWidth = 500),
                                subtitles = playable.subtitles,
                                subtitlesEnabled = playable.subtitlesEnabled,
                                selectedSubtitleId = playable.selectedSubtitleId,
                                subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
                                trickplayAvailable = playable.trickplayAvailable,
                                skipIntroAvailable = playable.skipIntroAvailable,
                                endCreditsAvailable = playable.endCreditsAvailable,
                                mediaSourceId = playable.mediaSourceId,
                                playSessionId = playable.playSessionId,
                                playMethod = playable.playMethod,
                                requestStartedAtMs = playbackRequestedAtMs,
                                ),
                            )
                        }
                        if (launched.isFailure) {
                            Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
                            launchingItem = null
                        }
                    }
                    .onFailure { error ->
                        // A cancellation is the viewer having pressed Back out of the
                        // wait, which has already reopened the gate and said so on screen.
                        if (error is kotlinx.coroutines.CancellationException) throw error
                        Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
                        // Nothing was launched, so nothing will come back to reopen the gate.
                        launchingItem = null
                    }
            } finally {
                resolvingItem = null
                resolveJob = null
            }
        }
    }

    val rows = remember(
        homeContent,
        forYouState,
        selectedDestination,
        settings.homeSections,
        settings.showHomeCardMetadata,
        settings.hideWatchedMovies,
    ) {
        val destinationRows = if (selectedDestination == BrowseDestination.FOR_YOU) {
            forYouBrowseRows(forYouState)
        } else {
            homeRowsFor(selectedDestination, homeContent, settings)
        }
        applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
    }
    // Keyed on the day as well as the rows, so the feature changes when the date does and
    // not merely when the launcher happens to be rebuilt.
    val heroDay = rememberHomeHeroDay()
    // The server rows are passed in *unfiltered*, beside the browse rows the launcher
    // draws: serverHomeRows drops the hero row, because it is consumed here rather than
    // rendered as a shelf, so this is the only thing that can still see it.
    val homeHeroMovies = remember(rows, homeContent.rows, selectedDestination, heroDay) {
        if (selectedDestination == BrowseDestination.HOME) {
            selectHomeHeroMovies(rows, heroDay, homeContent.rows)
        } else {
            emptyList()
        }
    }
    val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
        shouldShowHomeGreeting(
            hasHero = homeHeroMovies.isNotEmpty(),
            focusedRowId = focusedHomeRowId,
            rowIds = rows.map(HomeBrowseRow::id),
        )
    LaunchedEffect(selectedDestination) {
        focusedHomeRowId = null
        if (selectedDestination == BrowseDestination.FAVORITES) {
            recentSearches = repo.getRecentSearches()
        }
    }
    LaunchedEffect(selectedDestination, settings.forYouMinutes) {
        if (
            selectedDestination == BrowseDestination.FOR_YOU &&
            (forYouState.rows.isEmpty() || forYouState.availableMinutes != settings.forYouMinutes)
        ) {
            homeViewModel.loadForYou(settings.forYouMinutes)
        }
    }
    LaunchedEffect(selectedDestination, settings.hasOpenedForYou) {
        if (selectedDestination == BrowseDestination.FOR_YOU && !settings.hasOpenedForYou) {
            repo.markForYouOpened()
        }
    }
    val journeyScreen = when {
        showSettings -> "settings"
        showNotifications -> "notifications"
        userSwitcherVisible || showProfiles -> "profiles"
        detailsItem != null -> "details"
        selectedMyShow != null -> "my_show_details"
        genreBrowseItemType != null -> "genre_browser"
        else -> selectedDestination.name.lowercase()
    }
    LaunchedEffect(journeyScreen) {
        homeViewModel.trackJourney(
            category = "navigation", action = "screen_view", screen = journeyScreen,
            feature = journeyScreen, target = journeyScreen,
        )
    }
    val latestJourneyScreen by rememberUpdatedState(journeyScreen)
    LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) {
        if (
            showForYouNudge &&
            selectedDestination == BrowseDestination.HOME &&
            !settings.hasOpenedForYou
        ) {
            kotlinx.coroutines.delay(5_000L)
            showForYouNudge = false
        }
    }
    LaunchedEffect(
        selectedDestination,
        homeHeroMovies.firstOrNull()?.item?.id,
        rows.firstOrNull()?.items?.firstOrNull()?.id,
    ) {
        val firstItem = homeHeroMovies.firstOrNull()?.item
            ?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
        firstItem?.let(homeViewModel::focusItem)
        if (firstItem != null && !initialFocusRequested) {
            kotlinx.coroutines.delay(16L)
            if (runCatching { contentFocusRequester.requestFocus() }.isSuccess) {
                initialFocusRequested = true
            }
        }
    }

    // Engagement is uploaded on a timer, but a viewer who opens the player after sitting
    // on one row for a minute would otherwise lose that minute. Flushing on ON_STOP (and
    // on dispose) closes the open dwell measurement while it still means something.
    val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner, homeViewModel) {
        val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
            if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics()
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
            homeViewModel.endJourney(latestJourneyScreen)
        }
    }

    val contentShift by animateDpAsState(
        targetValue = if (navigationExpanded) TvRailContentShift else 0.dp,
        animationSpec = tween(150),
        label = "navigation-content-shift",
    )
    Box(Modifier.fillMaxSize().background(MembySurface)) {
        Row(Modifier.fillMaxSize()) {
            TvNavigationRail(
                selected = selectedDestination,
                focusDestination = railFocusDestination,
                expanded = navigationExpanded,
                navigationFocusRequester = navigationFocusRequester,
                onRailFocusChanged = {
                    navigationExpanded = it
                },
                onDestinationSelected = { destination ->
                    homeViewModel.trackJourney(
                        category = "navigation", action = "select",
                        screen = journeyScreen, feature = destination.name.lowercase(),
                        source = journeyScreen, target = destination.name.lowercase(),
                    )
                    genreBrowseItemType = null
                    when (destination) {
                        BrowseDestination.SETTINGS -> {
                            railFocusDestination = BrowseDestination.SETTINGS
                            restoreRailAfterSettings = true
                            navigationExpanded = false
                            userSwitcherVisible = false
                            showSettings = true
                        }
                        BrowseDestination.PROFILES -> {
                            railFocusDestination = BrowseDestination.PROFILES
                            userSwitcherVisible = true
                            navigationExpanded = false
                            // The rail stays live beside Settings, so a destination
                            // chosen from there has to close it or it would sit over
                            // whatever was just asked for.
                            showSettings = false
                            restoreRailAfterSettings = false
                        }
                        else -> {
                            navigationExpanded = false
                            userSwitcherVisible = false
                            showSettings = false
                            restoreRailAfterSettings = false
                            selectedDestination = destination
                            railFocusDestination = destination
                            val savedFocus = destinationFocus[destination]
                            savedFocus?.let { (rowId, itemId) ->
                                returnRowId = rowId
                                returnItemId = itemId
                            }
                            if (
                                savedFocus != null &&
                                savedFocus.first != HOME_HERO_ROW_ID &&
                                savedFocus.first != SEARCH_ROW_ID
                            ) {
                                // A low shelf may have fallen out of LazyColumn composition
                                // while the rail owned focus. Let the list scroll it back in
                                // before asking its card for focus.
                                rowListFocusRestoreRequest += 1
                            } else {
                                scope.launch {
                                    // Let the destination compose and attach its entry target
                                    // before transferring focus out of the rail.
                                    kotlinx.coroutines.delay(16L)
                                    if (savedFocus != null) {
                                        requestFirstAvailableFocus(
                                            cardReturnFocusRequester,
                                            contentFocusRequester,
                                            navigationFocusRequester,
                                        )
                                    } else {
                                        requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
                                    }
                                }
                            }
                        }
                    }
                },
                alertCount = notificationState.notifications.size,
                activeUsername = settings.username.orEmpty(),
                calendarEnabled = tvCalendarEnabled,
            )
            androidx.compose.foundation.layout.BoxWithConstraints(
                modifier = Modifier
                    .weight(1f)
                    .fillMaxSize()
                    .offset { IntOffset(x = contentShift.roundToPx(), y = 0) },
            ) {
                val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
                if (maintenanceMessage != null) {
                    // The rows are gone but the rail is not: Settings and Switch user are
                    // local, so there is no reason to strand the viewer here.
                    MaintenanceScreen(
                        message = maintenanceMessage,
                        contentFocusRequester = contentFocusRequester,
                        navigationFocusRequester = navigationFocusRequester,
                        onRetry = homeViewModel::refreshAll,
                    )
                    LaunchedEffect(Unit) {
                        kotlinx.coroutines.delay(450L)
                        runCatching { contentFocusRequester.requestFocus() }
                    }
                    return@BoxWithConstraints
                }

                if (selectedDestination == BrowseDestination.SEARCH) {
                    // Remembered, or this list is a fresh instance on every recomposition
                    // and the screen re-derives its genre chips each time.
                    val discovery = remember(homeContent.rows, homeContent.latestMovies, homeContent.continueWatching) {
                        homeContent.rows.flatMap { it.items }
                            .ifEmpty { homeContent.continueWatching + homeContent.latestMovies }
                            .distinctBy(BaseItem::id)
                    }
                    // Its own pane rather than a row list: the keyboard and the results
                    // need the whole content area. The rail stays beside it, and results
                    // open the same details overlay the rows do.
                    SearchScreen(
                        navigationFocusRequester = navigationFocusRequester,
                        contentFocusRequester = contentFocusRequester,
                        // Already in memory from the home response — the empty state
                        // costs nothing to fill.
                        discoveryItems = discovery,
                        returnFocusItemId = returnItemId.takeIf { returnRowId == SEARCH_ROW_ID },
                        returnFocusRequester = cardReturnFocusRequester,
                        initialQuery = initialSearchQuery,
                        onInitialQueryConsumed = { initialSearchQuery = null },
                        onSearchStarted = {
                            homeViewModel.trackJourney(
                                category = "search", action = "start", screen = "search",
                                feature = "search", source = "search", target = "search_results",
                            )
                        },
                        onItemFocused = homeViewModel::focusItem,
                        onItemSelected = { item ->
                            returnRowId = SEARCH_ROW_ID
                            returnItemId = item.id
                            destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
                            homeViewModel.focusItem(item)
                            if (item.membyPlayable) {
                                homeViewModel.trackJourney(
                                    category = "content", action = "open", screen = "search",
                                    feature = "search", source = "search_results", target = "details",
                                    itemId = item.id, itemType = item.type,
                                )
                                detailsAiringNotice = null
                                detailsItem = item
                            }
                        },
                        onContentFocused = { navigationExpanded = false },
                        onExit = {
                            selectedDestination = BrowseDestination.HOME
                            scope.launch {
                                kotlinx.coroutines.delay(16L)
                                runCatching { contentFocusRequester.requestFocus() }
                            }
                        },
                    )
                    return@BoxWithConstraints
                }

                if (selectedDestination == BrowseDestination.CALENDAR) {
                    // Its own pane rather than a row list: a month grid needs the whole
                    // content area, and there is no shelf shape that answers "what is on
                    // in three weeks". The rail stays beside it as it does for Search.
                    CalendarScreen(
                        navigationFocusRequester = navigationFocusRequester,
                        contentFocusRequester = contentFocusRequester,
                        onItemFocused = homeViewModel::focusItem,
                        onItemSelected = { item ->
                            homeViewModel.trackJourney(
                                category = "content", action = "open", screen = "calendar",
                                feature = "tv_calendar", source = "calendar_day",
                                target = "details", itemId = item.id, itemType = item.type,
                            )
                            // The same substitution the schedule row makes: a calendar card
                            // is an episode that has not aired, so what was asked for is the
                            // show, carrying the air time across because that is why it was
                            // pressed.
                            val seriesStub = scheduleSeriesStub(item)
                            if (seriesStub != null) {
                                detailsAiringNotice = airingNoticeFor(item)
                                homeViewModel.focusItem(seriesStub)
                                detailsItem = seriesStub
                            } else if (item.membyPlayable) {
                                detailsAiringNotice = null
                                homeViewModel.focusItem(item)
                                detailsItem = item
                            }
                        },
                        onExit = {
                            selectedDestination = BrowseDestination.HOME
                            scope.launch {
                                kotlinx.coroutines.delay(16L)
                                runCatching { contentFocusRequester.requestFocus() }
                            }
                        },
                    )
                    return@BoxWithConstraints
                }

                genreBrowseItemType?.let { itemType ->
                    GenreBrowseScreen(
                        itemType = itemType,
                        initialCategoryId = genreBrowseInitialCategoryId
                            ?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
                        favouriteStates = favoriteChanges,
                        playedStates = playedChanges,
                        navigationFocusRequester = navigationFocusRequester,
                        contentFocusRequester = contentFocusRequester,
                        returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
                        returnFocusRequester = cardReturnFocusRequester,
                        onItemFocused = homeViewModel::focusItem,
                        onItemSelected = { item ->
                            returnRowId = GENRE_BROWSER_ROW_ID
                            returnItemId = item.id
                            homeViewModel.focusItem(item)
                            homeViewModel.trackJourney(
                                category = "content", action = "open", screen = "genre_browser",
                                feature = "genre_browse", source = "genre_results", target = "details",
                                itemId = item.id, itemType = item.type,
                            )
                            detailsAiringNotice = null
                            detailsItem = item
                        },
                        onClose = {
                            genreBrowseItemType = null
                            genreBrowseInitialCategoryId = null
                            scope.launch {
                                kotlinx.coroutines.delay(16L)
                                runCatching { contentFocusRequester.requestFocus() }
                            }
                        },
                    )
                    return@BoxWithConstraints
                }

                FocusedHomeBackdrop(homeViewModel)
                val verticalState = verticalStates.getOrPut(selectedDestination.name) {
                    LazyListState()
                }
                val homeListAtTop by remember(verticalState) {
                    derivedStateOf {
                        verticalState.firstVisibleItemIndex == 0 &&
                            verticalState.firstVisibleItemScrollOffset == 0
                    }
                }
                val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
                // Set by a card in a shelf taking focus and cleared by the hero taking it
                // back. Keyed on the destination so arriving at Home never inherits where
                // focus happened to be on another one.
                var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) }
                val showHomeHero = shouldShowHomeMovieHero(
                    hasMovies = hasHomeHero,
                    listAtTop = homeListAtTop,
                    rowFocused = rowFocusedBelowHero,
                )
                val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero)
                val contentWidth = maxWidth
                HomeArtworkPreloader(rows = rows, availableWidth = contentWidth)
                val rowFocusPositions = rememberedRowFocus.getOrPut(selectedDestination) {
                    mutableMapOf()
                }
                val rowIndexes = remember(rows) {
                    rows.mapIndexed { index, row -> row.id to index }.toMap()
                }
                val rowItemCounts = remember(rows) { rows.map { it.items.size } }
                var pendingRowFocus by remember(selectedDestination) {
                    mutableStateOf<RowFocusRequest?>(null)
                }
                var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
                var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) }
                // The latch above is what stops a held D-pad stacking one move on top of
                // another, and until now the only thing that lifted it was the destination
                // row reporting the request consumed. A request nothing consumes therefore
                // took every later Up and Down press with it — the row list stopped moving
                // where it stood and the only way out was the rail. Nothing may consume it
                // when the destination row was not composed by the scroll, when that scroll
                // was interrupted by another one, or when the row is replaced by an arriving
                // refresh while the request is in flight. So the latch is bounded rather
                // than trusted: whatever happened, one press can hold vertical navigation
                // for ROW_FOCUS_MOVE_TIMEOUT_MS and no longer.
                LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) {
                    if (!rowFocusMoving) return@LaunchedEffect
                    kotlinx.coroutines.delay(ROW_FOCUS_MOVE_TIMEOUT_MS)
                    pendingRowFocus = null
                    rowFocusMoving = false
                }
                val firstPopulatedRowId = remember(rows) {
                    rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
                }
                // Must agree exactly with the items placed above the rows in the LazyColumn
                // below, because it is what turns a row index into a scroll target. The
                // genre strip is only one of them while the gateway has that feature on —
                // counting it regardless scrolled a row short of the destination on Movies
                // and TV Shows, which lands the requested card outside the composed window
                // and leaves the move with nothing to complete it.
                val leadingItemCount =
                    (
                        if (
                            genreBrowserEnabled &&
                            (selectedDestination == BrowseDestination.MOVIES ||
                                selectedDestination == BrowseDestination.SHOWS)
                        ) 1 else 0
                    ) +
                    (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
                    if (
                        selectedDestination == BrowseDestination.FAVORITES &&
                        recentSearches.isNotEmpty()
                    ) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0
                LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) {
                    if (rowListFocusRestoreRequest == 0) return@LaunchedEffect
                    val rowId = returnRowId ?: run {
                        rowListFocusRestoreRequest = 0
                        return@LaunchedEffect
                    }
                    val rowIndex = rows.indexOfFirst { it.id == rowId }
                    if (rowIndex < 0) {
                        rowListFocusRestoreRequest = 0
                        kotlinx.coroutines.delay(16L)
                        runCatching { contentFocusRequester.requestFocus() }
                        return@LaunchedEffect
                    }
                    // FocusRequester cannot focus a lazy child that is not composed. The
                    // airing shelf is commonly just beyond the retained viewport, which
                    // made focus fall back to its still-attached neighbour above.
                    verticalState.scrollToItem(leadingItemCount + rowIndex)
                    repeat(3) {
                        kotlinx.coroutines.delay(16L)
                        if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) {
                            rowListFocusRestoreRequest = 0
                            return@LaunchedEffect
                        }
                    }
                    rowListFocusRestoreRequest = 0
                }
                Column(Modifier.fillMaxSize()) {
                    if (showHomeHero) {
                        HomeMovieHero(
                            movies = homeHeroMovies,
                            navigationFocusRequester = navigationFocusRequester,
                            contentEntryFocusRequester = contentFocusRequester,
                            returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
                            returnFocusRequester = cardReturnFocusRequester,
                            // Only when there is a card down there to attach it to: a
                            // requester naming nothing throws the moment Down is pressed.
                            downFocusRequester = heroRowEntryFocusRequester.takeIf {
                                firstPopulatedRowId != null
                            },
                            onItemFocused = { item ->
                                navigationExpanded = false
                                rowFocusedBelowHero = false
                                focusedHomeRowId = null
                                destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
                                returnRowId = HOME_HERO_ROW_ID
                                returnItemId = item.id
                                homeViewModel.focusItem(item)
                            },
                            onItemSelected = { item ->
                                returnRowId = HOME_HERO_ROW_ID
                                returnItemId = item.id
                                homeViewModel.focusItem(item)
                                playItem(item)
                            },
                            modifier = Modifier.height(metadataHeight),
                        )
                    } else {
                        FocusedHomeMetadata(
                            homeViewModel = homeViewModel,
                            sectionLabel = selectedDestination.label,
                            modifier = Modifier
                                .height(metadataHeight)
                                .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp),
                        )
                    }
                    androidx.compose.animation.AnimatedVisibility(
                        visible = homeStatus.hasRefreshError,
                        enter = androidx.compose.animation.fadeIn(tween(180)),
                        exit = androidx.compose.animation.fadeOut(tween(120)),
                    ) {
                        Text(
                            // The gateway's maintenance notice when it sent one, and the
                            // generic wording otherwise.
                            homeStatus.statusMessage ?: "Connection is slow — showing available content",
                            color = MembyMutedText,
                            fontSize = 14.sp,
                            modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp),
                        )
                    }
                    LazyColumn(
                        state = verticalState,
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxWidth(),
                        contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
                        verticalArrangement = Arrangement.spacedBy(14.dp),
                    ) {
                        if (
                            genreBrowserEnabled &&
                            (selectedDestination == BrowseDestination.MOVIES ||
                                selectedDestination == BrowseDestination.SHOWS)
                        ) {
                            item(key = "genre-browser", contentType = "genre-browser") {
                                val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
                                GenreDiscoveryStrip(
                                    itemType = itemType,
                                    navigationFocusRequester = navigationFocusRequester,
                                    entryFocusRequester = contentFocusRequester,
                                    onFocused = { navigationExpanded = false },
                                    onOpenCategory = { categoryId ->
                                        homeViewModel.trackJourney(
                                            category = "navigation", action = "open",
                                            screen = selectedDestination.name.lowercase(), feature = "genre_browse",
                                            source = "genre_strip", target = "genre_browser",
                                        )
                                        genreBrowseInitialCategoryId = categoryId
                                        genreBrowseItemType = itemType
                                    },
                                )
                            }
                        }
                        if (selectedDestination == BrowseDestination.SHOWS) {
                            item(key = "my-shows", contentType = "my-shows") {
                                MyShowsStrip(
                                    shows = myShows,
                                    loading = myShowsLoading,
                                    errorMessage = myShowsError,
                                    onRetry = {
                                        if (!myShowsLoading) scope.launch {
                                            myShowsLoading = true
                                            myShowsError = null
                                            runCatching { repo.getMyShows() }
                                                .onSuccess { myShows = it }
                                                .onFailure { myShowsError = friendlyEmbyError(it) }
                                            myShowsLoading = false
                                        }
                                    },
                                    repository = repo,
                                    availableWidth = contentWidth,
                                    density = settings.homeCardDensity,
                                    navigationFocusRequester = navigationFocusRequester,
                                    // The genre launcher owns the entry target only while
                                    // the server has enabled it. When it is off, My Shows
                                    // becomes the first reachable content on this page.
                                    contentFocusRequester = contentFocusRequester.takeUnless {
                                        genreBrowserEnabled
                                    },
                                    returnFocusItemId = myShowReturnItemId,
                                    returnFocusRequester = myShowReturnFocusRequester,
                                    onShowSelected = {
                                        homeViewModel.trackJourney(
                                            category = "content", action = "open", screen = "shows",
                                            feature = "my_shows", source = "my_shows", target = "my_show_details",
                                            itemId = it.itemId,
                                        )
                                        myShowReturnItemId = it.itemId
                                        selectedMyShow = it
                                    },
                                    onContentFocused = { navigationExpanded = false },
                                )
                            }
                        }
                        if (
                            selectedDestination == BrowseDestination.FAVORITES &&
                            recentSearches.isNotEmpty()
                        ) {
                            item(key = "recent-searches", contentType = "recent-searches") {
                                RecentSearchesRow(
                                    queries = recentSearches,
                                    navigationFocusRequester = navigationFocusRequester,
                                    onContentFocused = { navigationExpanded = false },
                                    onQuerySelected = { query ->
                                        homeViewModel.trackJourney(
                                            category = "search", action = "select", screen = "favourites",
                                            feature = "recent_searches", source = "recent_searches", target = "search",
                                        )
                                        initialSearchQuery = query
                                        selectedDestination = BrowseDestination.SEARCH
                                    },
                                )
                            }
                        }
                        if (selectedDestination == BrowseDestination.FOR_YOU) {
                            item(key = "for-you-time", contentType = "for-you-time") {
                                ForYouTimeBudget(
                                    selectedMinutes = forYouState.availableMinutes,
                                    loading = forYouState.loading,
                                    error = forYouState.error,
                                    onSelected = { minutes ->
                                        homeViewModel.trackJourney(
                                            category = "recommendations", action = "change", screen = "for_you",
                                            feature = "for_you_time", outcome = "success",
                                        )
                                        homeViewModel.loadForYou(minutes)
                                        scope.launch { repo.setForYouMinutes(minutes) }
                                    },
                                )
                            }
                        }
                        items(
                            items = rows,
                            key = { it.id },
                            contentType = { "media-row-${it.kind}" },
                        ) { row ->
                            // LazyColumn composes a row as it scrolls into view, which is
                            // as close to "the viewer saw it" as the TV can observe.
                            LaunchedEffect(row.id) {
                                // The first eight posters cover the initial TV viewport
                                // with headroom across density settings. Later posters
                                // only become evidence when focus actually reaches them.
                                homeViewModel.trackRowImpression(
                                    row.id,
                                    row.kind.name,
                                    row.items.take(8).map { it.id },
                                )
                            }
                            MediaRow(
                                row = row,
                                availableWidth = contentWidth,
                                navigationFocusRequester = navigationFocusRequester,
                                contentEntryFocusRequester = contentFocusRequester.takeIf {
                                    !hasHomeHero &&
                                    (selectedDestination != BrowseDestination.SHOWS ||
                                        (!genreBrowserEnabled && myShows.isEmpty())) &&
                                        (selectedDestination != BrowseDestination.MOVIES ||
                                            !genreBrowserEnabled) &&
                                        row.id == firstPopulatedRowId
                                },
                                heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
                                    hasHomeHero && row.id == firstPopulatedRowId
                                },
                                returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
                                returnFocusRequester = cardReturnFocusRequester,
                                verticalFocusRequest = pendingRowFocus,
                                onVerticalFocusRequestConsumed = { requestId ->
                                    if (pendingRowFocus?.requestId == requestId) {
                                        pendingRowFocus = null
                                        rowFocusMoving = false
                                    }
                                },
                                onMoveVertical = moveVertical@{ sourceItemIndex, direction ->
                                    if (rowFocusMoving) return@moveVertical true
                                    val sourceRowIndex = rowIndexes[row.id] ?: return@moveVertical false
                                    val destinationRowIndex = adjacentFocusableRowIndex(
                                        itemCounts = rowItemCounts,
                                        currentIndex = sourceRowIndex,
                                        direction = direction,
                                    ) ?: run {
                                        // Up out of the topmost shelf is the way back to the
                                        // hero, and it has to be handled here: the hero is
                                        // not composed while a row holds focus, so there is
                                        // nothing above for Compose's own focus search to
                                        // find and the press would otherwise be dead.
                                        if (direction == RowFocusDirection.UP && hasHomeHero) {
                                            rowFocusedBelowHero = false
                                            scope.launch {
                                                verticalState.animateScrollToItem(0)
                                                // Let the hero compose and attach its entry
                                                // target before focus is handed to it.
                                                kotlinx.coroutines.delay(16L)
                                                runCatching { contentFocusRequester.requestFocus() }
                                            }
                                            return@moveVertical true
                                        }
                                        return@moveVertical false
                                    }
                                    val destinationRow = rows[destinationRowIndex]
                                    val destinationItemIndex = rowEntryItemIndex(
                                        sourceIndex = sourceItemIndex,
                                        destinationItemCount = destinationRow.items.size,
                                        rememberedDestinationIndex = rowFocusPositions[destinationRow.id],
                                    )
                                    rowFocusPositions[row.id] = sourceItemIndex
                                    rowFocusPositions[destinationRow.id] = destinationItemIndex
                                    rowFocusRequestId += 1
                                    val request = RowFocusRequest(
                                        rowId = destinationRow.id,
                                        itemIndex = destinationItemIndex,
                                        requestId = rowFocusRequestId,
                                    )
                                    rowFocusMoving = true
                                    scope.launch {
                                        try {
                                            // Compose the destination row before its MediaRow
                                            // tries to attach the requested card's focus node.
                                            verticalState.animateScrollToItem(
                                                leadingItemCount + destinationRowIndex,
                                            )
                                        } catch (cancelled: kotlinx.coroutines.CancellationException) {
                                            // Another scroll took the list over mid-animation, so
                                            // this move is never going to post its request and
                                            // nothing downstream would ever lift the latch. Ask
                                            // for the card anyway — the row it is in is where the
                                            // list has been left, and the worst case is the
                                            // request expiring like any other.
                                            pendingRowFocus = request
                                            throw cancelled
                                        }
                                        pendingRowFocus = request
                                    }
                                    true
                                },
                                onContentFocused = { navigationExpanded = false },
                                onItemFocused = { item ->
                                    val itemIndex = row.items.indexOfFirst { it.id == item.id }
                                    if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex
                                    rowFocusedBelowHero = true
                                    if (selectedDestination == BrowseDestination.HOME) {
                                        focusedHomeRowId = row.id
                                    }
                                    destinationFocus[selectedDestination] = row.id to item.id
                                    returnRowId = row.id
                                    returnItemId = item.id
                                    homeViewModel.focusItem(item)
                                    homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
                                },
                                onItemSelected = { item ->
                                    returnRowId = row.id
                                    returnItemId = item.id
                                    homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
                                    homeViewModel.trackJourney(
                                        category = "content", action = "open",
                                        screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
                                        source = row.id, target = if (item.membyPlayable) "details" else "content_action",
                                        itemId = item.id, itemType = item.type,
                                    )
                                    // A schedule card is an episode that has not aired, so
                                    // it is not playable and has no page of its own. What
                                    // the viewer asked for is the show — carrying the air
                                    // time across, since that is why they pressed it.
                                    val seriesStub = scheduleSeriesStub(item)
                                    if (seriesStub != null) {
                                        detailsAiringNotice = airingNoticeFor(item)
                                        homeViewModel.focusItem(seriesStub)
                                        detailsItem = seriesStub
                                    } else {
                                        homeViewModel.focusItem(item)
                                        if (item.membyPlayable) {
                                            detailsAiringNotice = null
                                            detailsItem = item
                                        }
                                    }
                                },
                                onItemLongPressed = { item ->
                                    returnRowId = row.id
                                    returnItemId = item.id
                                    homeViewModel.focusItem(item)
                                    if (item.membyPlayable) {
                                        quickMenuRowId = row.id
                                        quickMenuItem = item
                                    }
                                },
                                density = settings.homeCardDensity,
                                artworkStyle = settings.homeArtworkStyle,
                                horizontalState = horizontalStates.getOrPut(
                                    "${selectedDestination.name}:${row.id}",
                                ) { LazyListState() },
                            )
                        }
                    }
                }
            }
        }
        androidx.compose.animation.AnimatedVisibility(
            visible = compatibilityNotice != null,
            modifier = Modifier.align(Alignment.TopCenter),
            enter = androidx.compose.animation.fadeIn(tween(180)),
            exit = androidx.compose.animation.fadeOut(tween(120)),
        ) {
            Text(
                text = compatibilityNotice?.message.orEmpty(),
                color = Color.White,
                fontSize = 16.sp,
                fontWeight = FontWeight.SemiBold,
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color(0xFFD6403A))
                    .padding(horizontal = 36.dp, vertical = 12.dp),
            )
        }
        HomeClock(
            showGreeting = showHomeGreeting,
            username = settings.username,
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(end = 24.dp, bottom = 18.dp),
        )
        if (userSwitcherVisible) {
            BackHandler {
                userSwitcherVisible = false
                scope.launch {
                    kotlinx.coroutines.delay(16L)
                    runCatching { navigationFocusRequester.requestFocus() }
                }
            }
            UserSwitcherOverlay(
                profiles = settings.profiles,
                activeProfileId = settings.activeProfileId,
                onProfileSelected = { profile ->
                    userSwitcherVisible = false
                    navigationExpanded = false
                    if (profile.id != settings.activeProfileId) {
                        switchingProfileId = profile.id
                        scope.launch {
                            homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
                            runCatching { repo.switchProfile(profile) }
                                .onFailure { switchingProfileId = null }
                        }
                    } else {
                        scope.launch {
                            kotlinx.coroutines.delay(16L)
                            runCatching { navigationFocusRequester.requestFocus() }
                        }
                    }
                },
                onManageProfiles = {
                    userSwitcherVisible = false
                    navigationExpanded = false
                    showProfiles = true
                },
                alertCount = notificationState.notifications.size,
                onOpenAlerts = {
                    homeViewModel.trackJourney(
                        category = "notifications", action = "open", screen = journeyScreen,
                        feature = "notifications", target = "notifications",
                    )
                    userSwitcherVisible = false
                    navigationExpanded = false
                    showNotifications = true
                    scope.launch {
                        notificationsLoading = true
                        notificationsError = null
                        runCatching { repo.getNotifications() }
                            .onSuccess { notificationState = it }
                            .onFailure { notificationsError = friendlyEmbyError(it) }
                        notificationsLoading = false
                    }
                },
                onDismiss = {
                    userSwitcherVisible = false
                    scope.launch {
                        kotlinx.coroutines.delay(16L)
                        runCatching { navigationFocusRequester.requestFocus() }
                    }
                },
            )
        }
        if (showSettings) {
            val closeSettings: () -> Unit = {
                showSettings = false
                scope.launch {
                    kotlinx.coroutines.delay(16L)
                    if (restoreRailAfterSettings) {
                        navigationExpanded = true
                        runCatching { navigationFocusRequester.requestFocus() }
                        restoreRailAfterSettings = false
                    } else if (returnItemId != null) {
                        requestFirstAvailableFocus(
                            cardReturnFocusRequester,
                            contentFocusRequester,
                            navigationFocusRequester,
                        )
                    } else {
                        runCatching { contentFocusRequester.requestFocus() }
                    }
                }
            }
            BackHandler(onBack = closeSettings)
            // Settings is a destination, not a takeover: it leaves the main rail visible
            // and reachable so Home, Search and Switch user are one Left press away, the
            // same as from every other page. The rail below is the live one — this Row
            // only reserves its collapsed footprint and takes the same expand shift the
            // content area does, so an expanded rail slides Settings aside rather than
            // covering its own page list.
            // The full expansion, not the home screen's TvRailContentShift: the rows
            // tolerate the rail drawing over their left edge, but this panel is painted
            // above the rail, so anything short of the whole width would clip the
            // labels off the very rail the viewer is moving through.
            val settingsShift by animateDpAsState(
                targetValue = if (navigationExpanded) {
                    TvRailExpandedWidth - TvRailCollapsedWidth
                } else {
                    0.dp
                },
                animationSpec = tween(150),
                label = "settings-content-shift",
            )
            Row(Modifier.fillMaxSize()) {
                Spacer(Modifier.width(TvRailCollapsedWidth))
                SettingsSheet(
                    onClose = closeSettings,
                    overlay = false,
                    navigationFocusRequester = navigationFocusRequester,
                    onAnalyticsEvent = { feature, action ->
                        homeViewModel.trackJourney(
                            category = "settings", action = action, screen = "settings",
                            feature = feature, outcome = "success",
                        )
                    },
                    modifier = Modifier
                        .weight(1f)
                        .offset { IntOffset(x = settingsShift.roundToPx(), y = 0) },
                )
            }
        }
        if (showProfiles) {
            BackHandler(onBack = { showProfiles = false })
            ProfileChooser(
                profiles = settings.profiles,
                currentProfileId = settings.activeProfileId,
                switchingProfileId = switchingProfileId,
                removingProfileId = removingProfileId,
                onSelect = { profile ->
                    if (profile.id == settings.activeProfileId) {
                        showProfiles = false
                    } else {
                        switchingProfileId = profile.id
                        scope.launch {
                            homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
                            runCatching { repo.switchProfile(profile) }
                                .onFailure { switchingProfileId = null }
                        }
                    }
                },
                onRemove = { profile ->
                    removingProfileId = profile.id
                    scope.launch {
                        runCatching { repo.removeProfile(profile) }
                        removingProfileId = null
                    }
                },
                onAddProfile = {
                    scope.launch {
                        homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
                        addingProfile = true
                    }
                },
                onClose = { showProfiles = false },
            )
        }
        // Over the manage-users page rather than instead of it: cancelling comes straight
        // back to the list the "+" was pressed from. A successful sign-in makes the new
        // viewer active, which re-keys HomeScreen and takes both flags with it.
        if (addingProfile) {
            SetupScreen(
                onCancel = { addingProfile = false },
                onSignedIn = { addingProfile = false },
            )
        }
        detailsItem?.let { selected ->
            BackHandler {
                val previous = detailsTrail.lastOrNull()
                if (previous != null) {
                    detailsTrail = detailsTrail.dropLast(1)
                    restoreDetailPosition = true
                    detailsItem = previous
                } else {
                    restoreDetailPosition = false
                    detailsItem = null
                    detailsAiringNotice = null
                    requestFirstAvailableFocus(
                        cardReturnFocusRequester,
                        contentFocusRequester,
                        navigationFocusRequester,
                    )
                }
            }
            FocusedDetailsOverlay(
                homeViewModel = homeViewModel,
                selected = selected,
                restorePosition = restoreDetailPosition,
                airingNotice = detailsAiringNotice,
                onOpenItem = { related ->
                    homeViewModel.trackJourney(
                        category = "recommendations", action = "open", screen = "details",
                        feature = "related", source = "related", target = "details",
                        itemId = related.id, itemType = related.type,
                    )
                    detailsTrail = detailsTrail + selected
                    restoreDetailPosition = false
                    // Ask for the full record the same way a focused card does. Some
                    // routes here hand over a stub rather than a row item — the episode
                    // page's "Show" button knows only the series' id and name — and
                    // without this the page opens with no logo, no backdrop and no facts,
                    // because nothing else on this path ever fetches the item.
                    homeViewModel.focusItem(related)
                    detailsItem = related
                    // The notice was about the show that was pressed in the row, not about
                    // whatever "More like this" leads to. Walking Back does not restore it,
                    // and should not: it was news, and it has been read.
                    detailsAiringNotice = null
                },
                onPlay = {
                    detailsItem = null
                    detailsTrail = emptyList()
                    restoreDetailPosition = false
                    detailsAiringNotice = null
                    playItem(it)
                },
                onToggleFavorite = { item, saved ->
                    homeViewModel.trackJourney(
                        category = "library", action = if (saved) "favourite" else "unfavourite",
                        screen = "details", feature = "favorites", itemId = item.id, itemType = item.type,
                        outcome = "success",
                    )
                    homeViewModel.setFavorite(item, saved)
                },
                isMyShow = myShows.any { it.itemId == selected.id },
                onToggleMyShow = { item, saved ->
                    homeViewModel.trackJourney(
                        category = "library", action = if (saved) "follow" else "unfollow",
                        screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type,
                        outcome = "success",
                    )
                    // Optimistic, the way a favourite already is. Following a show is a
                    // press with an obvious outcome, and the server's answer to it is the
                    // *whole* list decorated with Sonarr's lifecycle for every show on it
                    // — a request the viewer has no reason to sit through watching an
                    // unchanged button. The row that lands a moment later replaces this
                    // placeholder; a failure puts the button back where it was.
                    if (myShowsMutationBusy) return@FocusedDetailsOverlay
                    myShowsMutationBusy = true
                    val previous = myShows
                    myShows = if (saved) {
                        myShows.filterNot { it.itemId == item.id } + myShowStub(item)
                    } else {
                        myShows.filterNot { it.itemId == item.id }
                    }
                    if (saved) {
                        Toast.makeText(
                            context,
                            "${item.name} added to My Shows",
                            Toast.LENGTH_SHORT,
                        ).show()
                    }
                    scope.launch {
                        if (saved) {
                            runCatching { repo.saveMyShow(item) }
                                .onSuccess { myShows = it }
                                .onFailure { myShows = previous }
                        } else {
                            runCatching { repo.removeMyShow(item.id) }
                                .onFailure { myShows = previous }
                        }
                        myShowsMutationBusy = false
                    }
                },
                onTogglePlayed = { item, played ->
                    homeViewModel.trackJourney(
                        category = "library", action = if (played) "mark_played" else "mark_unplayed",
                        screen = "details", feature = "played_status", itemId = item.id, itemType = item.type,
                        outcome = "success",
                    )
                    homeViewModel.setPlayed(item, played)
                },
                onClose = {
                    detailsItem = null
                    detailsTrail = emptyList()
                    restoreDetailPosition = false
                    detailsAiringNotice = null
                    requestFirstAvailableFocus(
                        cardReturnFocusRequester,
                        contentFocusRequester,
                        navigationFocusRequester,
                    )
                },
            )
        }
        selectedMyShow?.let { show ->
            val closeMyShow: (Boolean) -> Unit = { removed ->
                selectedMyShow = null
                scope.launch {
                    kotlinx.coroutines.delay(16L)
                    val exactStillExists = !removed && myShows.any { it.itemId == myShowReturnItemId }
                    val restored = exactStillExists && runCatching {
                        myShowReturnFocusRequester.requestFocus()
                    }.isSuccess
                    if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) {
                        runCatching { navigationFocusRequester.requestFocus() }
                    }
                }
            }
            BackHandler { closeMyShow(false) }
            MyShowDetailsOverlay(
                show = show,
                repository = repo,
                removing = removingMyShow,
                onRemove = {
                    removingMyShow = true
                    scope.launch {
                        runCatching { repo.removeMyShow(show.itemId) }.onSuccess {
                            myShows = myShows.filterNot { it.itemId == show.itemId }
                            closeMyShow(true)
                        }
                        removingMyShow = false
                    }
                },
                onClose = { closeMyShow(false) },
            )
        }
        if (showNotifications) {
            // Reached from the user picker, so leaving it goes back to the rail rather than
            // to whatever card happened to hold focus on the launcher behind it.
            val closeAlerts: () -> Unit = {
                showNotifications = false
                scope.launch {
                    kotlinx.coroutines.delay(16L)
                    runCatching { navigationFocusRequester.requestFocus() }
                }
            }
            BackHandler(onBack = closeAlerts)
            MyAlertsPage(
                notifications = notificationState.notifications,
                preferences = notificationState.preferences,
                loading = notificationsLoading,
                errorMessage = notificationsError,
                onRetry = {
                    if (!notificationsLoading) scope.launch {
                        notificationsLoading = true
                        notificationsError = null
                        runCatching { repo.getNotifications() }
                            .onSuccess { notificationState = it }
                            .onFailure { notificationsError = friendlyEmbyError(it) }
                        notificationsLoading = false
                    }
                },
                onToggleEnabled = {
                    if (notificationsMutationBusy) return@MyAlertsPage
                    notificationsMutationBusy = true
                    scope.launch {
                        val updated = notificationState.preferences.copy(
                            enabled = !notificationState.preferences.enabled,
                        )
                        runCatching { repo.setNotificationPreferences(updated) }
                            .onSuccess { notificationState = it }
                            .onFailure { notificationsError = friendlyEmbyError(it) }
                        notificationsMutationBusy = false
                    }
                },
                onToggleShowReturns = {
                    if (notificationsMutationBusy) return@MyAlertsPage
                    notificationsMutationBusy = true
                    scope.launch {
                        val updated = notificationState.preferences.copy(
                            showReturnAlerts = !notificationState.preferences.showReturnAlerts,
                        )
                        runCatching { repo.setNotificationPreferences(updated) }
                            .onSuccess { notificationState = it }
                            .onFailure { notificationsError = friendlyEmbyError(it) }
                        notificationsMutationBusy = false
                    }
                },
                // Marked read locally first. This fires on *focus*, so on a slow
                // connection walking down the list and back up would send the same row's
                // request once per pass — clearing the flag immediately is what makes the
                // row stop asking.
                onRead = { notification ->
                    val previousReadAt = notification.readAt
                    notificationState = notificationState.copy(
                        notifications = notificationState.notifications.map {
                            if (it.id == notification.id) it.copy(readAt = "now") else it
                        },
                    )
                    scope.launch {
                        runCatching { repo.markNotificationRead(notification.id) }
                            .onFailure { failure ->
                                notificationState = notificationState.copy(
                                    notifications = notificationState.notifications.map {
                                        if (it.id == notification.id) {
                                            it.copy(readAt = previousReadAt)
                                        } else {
                                            it
                                        }
                                    },
                                )
                                notificationsError = friendlyEmbyError(failure)
                            }
                    }
                },
                // Optimistic, for the reason "Dismiss all" beneath it already is: this
                // page is judged entirely on emptying itself, and a row that stayed put
                // while its request was in flight is a row pressed again — which on this
                // page also re-aims where focus lands afterwards. A failure puts the row
                // back where it was rather than quietly losing somebody's alert.
                onDismiss = { notification ->
                    if (notificationsMutationBusy) return@MyAlertsPage
                    notificationsMutationBusy = true
                    val previous = notificationState
                    notificationState = notificationState.copy(
                        notifications = notificationState.notifications.filterNot {
                            it.id == notification.id
                        },
                    )
                    scope.launch {
                        runCatching { repo.dismissNotification(notification.id) }
                            .onFailure { failure ->
                                notificationState = previous
                                notificationsError = friendlyEmbyError(failure)
                            }
                        notificationsMutationBusy = false
                    }
                },
                // The gateway has no bulk route, so this is the same call per alert. The
                // list is emptied optimistically: the page is judged on emptying itself,
                // and a row that lingered while its request was in flight would be pressed
                // a second time.
                onDismissAll = {
                    if (notificationsMutationBusy) return@MyAlertsPage
                    notificationsMutationBusy = true
                    val previous = notificationState
                    val pending = notificationState.notifications.map(UserNotification::id)
                    notificationState = notificationState.copy(notifications = emptyList())
                    scope.launch {
                        val failed = pending.filter { id ->
                            runCatching { repo.dismissNotification(id) }.isFailure
                        }
                        runCatching { repo.getNotifications() }
                            .onSuccess { notificationState = it }
                            .onFailure { failure ->
                                notificationState = previous
                                notificationsError = friendlyEmbyError(failure)
                            }
                        if (failed.isNotEmpty()) {
                            notificationsError = "Some alerts couldn’t be dismissed. Try again."
                        }
                        notificationsMutationBusy = false
                    }
                },
                onClose = closeAlerts,
            )
        }
        quickMenuItem?.let { selected ->
            val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
                quickMenuItem = null
                quickMenuRowId = null
                scope.launch {
                    // The overlay owns focus until it leaves composition. Restore the
                    // exact originating card after its focus node is available again.
                    kotlinx.coroutines.delay(16L)
                    if (originWillDisappear) {
                        requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
                    } else {
                        requestFirstAvailableFocus(
                            cardReturnFocusRequester,
                            contentFocusRequester,
                            navigationFocusRequester,
                        )
                    }
                }
            }
            BackHandler { closeQuickActions(false) }
            FocusedQuickActionsOverlay(
                homeViewModel = homeViewModel,
                selected = selected,
                onOpenDetails = {
                    quickMenuItem = null
                    detailsAiringNotice = null
                    detailsItem = it
                },
                onSetFavorite = homeViewModel::setFavorite,
                onSetPlayed = homeViewModel::setPlayed,
                onRemoveFromContinueWatching = if (
                    rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE
                ) {
                    {
                        quickMenuItem = null
                        quickMenuRowId = null
                        homeViewModel.removeFromContinueWatching(selected)
                        scope.launch {
                            kotlinx.coroutines.delay(16L)
                            val removalFocusRequester = if (
                                selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
                            ) {
                                heroRowEntryFocusRequester
                            } else {
                                contentFocusRequester
                            }
                            requestFirstAvailableFocus(
                                removalFocusRequester,
                                contentFocusRequester,
                                navigationFocusRequester,
                            )
                        }
                    }
                } else {
                    null
                },
                rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title,
                rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(),
                onToggleRowPinned = quickMenuRowId?.let { rowId ->
                    {
                        val pinned = settings.homePinnedRows.decodeRowIds().toMutableSet()
                        if (!pinned.add(rowId)) pinned.remove(rowId)
                        scope.launch {
                            ServiceLocator.settings.setHomeRowPreferences(
                                settings.homeRowOrder.decodeRowIds(),
                                pinned,
                                settings.homeHiddenRows.decodeRowIds().toSet(),
                            )
                        }
                        closeQuickActions(false)
                    }
                },
                onHideRow = quickMenuRowId?.let { rowId ->
                    {
                        val hidden = settings.homeHiddenRows.decodeRowIds().toMutableSet()
                        hidden.add(rowId)
                        scope.launch {
                            ServiceLocator.settings.setHomeRowPreferences(
                                settings.homeRowOrder.decodeRowIds(),
                                settings.homePinnedRows.decodeRowIds().toSet(),
                                hidden,
                            )
                            closeQuickActions(true)
                        }
                    }
                },
                onMoveRow = quickMenuRowId?.let { rowId ->
                    { direction ->
                        val currentIds = rows.map(HomeBrowseRow::id).toMutableList()
                        val index = currentIds.indexOf(rowId)
                        val target = (index + direction).coerceIn(0, currentIds.lastIndex)
                        if (index >= 0 && target != index) {
                            currentIds.removeAt(index)
                            currentIds.add(target, rowId)
                            scope.launch {
                                ServiceLocator.settings.setHomeRowPreferences(
                                    currentIds,
                                    settings.homePinnedRows.decodeRowIds().toSet(),
                                    settings.homeHiddenRows.decodeRowIds().toSet(),
                                )
                            }
                        }
                        closeQuickActions(false)
                    }
                },
                onClose = { closeQuickActions(false) },
            )
        }
        resolvingItem?.let { item ->
            // Back gets out of the wait. This overlay covers the whole screen while the
            // gateway is asked for a stream, and against a server that has stopped
            // answering that is the length of an HTTP timeout — long enough that a viewer
            // reaches for the remote, and until now long enough that nothing answered
            // them. Cancelling is safe: nothing has been launched and nothing reported.
            BackHandler(onBack = abandonLaunch)
            PlaybackLaunchOverlay(
                item = item,
                modifier = Modifier.fillMaxSize().zIndex(9f),
            )
        }
        ForYouNudgeBanner(
            visible = showForYouNudge &&
                selectedDestination == BrowseDestination.HOME &&
                !settings.hasOpenedForYou &&
                liveMaintenance == null,
            username = settings.username,
            modifier = Modifier.align(Alignment.TopCenter),
        )
        // Emby has stopped answering. Persistent, unlike the news bar below it, because
        // it describes a state rather than an event: a set switched on midway through an
        // outage was never told, and this is the only thing that says why nothing plays.
        // Suppressed under maintenance, which owns the screen and is its own explanation.
        EmbyOutageBanner(
            suppressed = liveMaintenance != null,
            modifier = Modifier.align(Alignment.TopCenter),
        )
        // News about the library, not about the app: it sits above the rows but is
        // suppressed whenever something more important owns the screen.
        ServiceAlertBanner(
            // The alert itself is collected inside the banner, so an arriving one does
            // not recompose this whole function. Only the suppression conditions — both
            // already read here for other reasons — cross the boundary.
            // The outage bar takes the same strip of screen and outranks any
            // announcement, including the one announcing this very outage.
            suppressed = liveMaintenance != null || embyOutage != null,
            // Flush to the top edge and spanning the rail: for its few seconds this is
            // the top layer of the screen, the way a broadcast notice is.
            modifier = Modifier.align(Alignment.TopCenter),
        )
    }
}

@Composable
private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) {
    val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 850, easing = LinearEasing),
            repeatMode = RepeatMode.Restart,
        ),
        label = "playback-logo-rotation",
    )
    Box(
        modifier = modifier.background(MembySurface.copy(alpha = 0.93f)),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Image(
                painter = painterResource(R.drawable.emby_logo),
                contentDescription = null,
                contentScale = ContentScale.Fit,
                modifier = Modifier
                    .width(82.dp)
                    .height(70.dp)
                    .graphicsLayer { rotationZ = rotation },
            )
            Text(
                "Starting ${item.name}",
                color = Color.White,
                fontSize = 21.sp,
                fontWeight = FontWeight.SemiBold,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
            Text(
                "Preparing direct playback…",
                color = MembyQuietText,
                fontSize = 14.sp,
            )
        }
    }
}

/** Row id under which the search grid records its return-focus target. */
private const val SEARCH_ROW_ID = "search-results"
private const val GENRE_BROWSER_ROW_ID = "genre-browser-results"

@Composable
private fun RecentSearchesRow(
    queries: List<String>,
    navigationFocusRequester: FocusRequester,
    onContentFocused: () -> Unit,
    onQuerySelected: (String) -> Unit,
) {
    Column(verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
        Row(
            modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            HomeRowHeaderIcon(Icons.Default.Search)
            Spacer(Modifier.width(HomeRowHeaderIconGap))
            Text(
                "Recent searches",
                color = MembyOnSurface,
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
            )
            Spacer(Modifier.width(10.dp))
            Text(
                "LAST 30 DAYS",
                color = MembyQuietText,
                fontSize = 10.sp,
                fontWeight = FontWeight.Bold,
            )
        }
        LazyRow(
            // Vertical padding so a focus-scaled chip has room to grow, like every other row.
            contentPadding = androidx.compose.foundation.layout.PaddingValues(
                horizontal = 36.dp,
                vertical = 9.dp,
            ),
            horizontalArrangement = Arrangement.spacedBy(9.dp),
        ) {
            items(queries, key = { it.lowercase() }) { query ->
                val first = query == queries.first()
                FocusScaleContainer(
                    onFocused = onContentFocused,
                    onClick = { onQuerySelected(query) },
                    contentDescription = "Search again for $query",
                    modifier = Modifier
                        .clip(RoundedCornerShape(MembyChipCorner))
                        .then(
                            if (first) {
                                Modifier.focusProperties { left = navigationFocusRequester }
                            } else {
                                Modifier
                            },
                        ),
                ) { focused ->
                    Text(
                        query,
                        color = if (focused) MembyAccentInk else MembyOnSurface,
                        fontSize = 14.sp,
                        fontWeight = FontWeight.SemiBold,
                        maxLines = 1,
                        modifier = Modifier
                            .background(
                                if (focused) MembyAccent else MembyControlSurface,
                            )
                            .padding(horizontal = 17.dp, vertical = 10.dp),
                    )
                }
            }
        }
    }
}

@Composable
private fun HomeClock(
    showGreeting: Boolean,
    username: String?,
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current
    val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
    var currentTime by remember { mutableStateOf(Date()) }

    LaunchedEffect(Unit) {
        while (true) {
            currentTime = Date()
            // Wake on the next minute boundary instead of drifting by a few seconds
            // every time the app is left open.
            val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L)
            kotlinx.coroutines.delay(untilNextMinute)
        }
    }

    val period = homeGreetingPeriod(
        Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY),
    )
    val name = friendlyProfileName(username)
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.End,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        androidx.compose.animation.AnimatedVisibility(
            visible = showGreeting && name != null,
            enter = androidx.compose.animation.fadeIn(tween(180)),
            exit = androidx.compose.animation.fadeOut(tween(120)),
        ) {
            Row(
                modifier = Modifier
                    .clip(RoundedCornerShape(8.dp))
                    .background(MembySurface.copy(alpha = 0.70f))
                    .padding(horizontal = 12.dp, vertical = 7.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Icon(
                    imageVector = when (period) {
                        HomeGreetingPeriod.MORNING -> Icons.Default.LightMode
                        HomeGreetingPeriod.AFTERNOON -> Icons.Default.WbSunny
                        HomeGreetingPeriod.EVENING -> Icons.Default.NightsStay
                    },
                    contentDescription = null,
                    tint = MembyAccent,
                    modifier = Modifier.size(18.dp),
                )
                Spacer(Modifier.width(8.dp))
                Text(
                    text = "${period.words}, $name",
                    color = Color.White.copy(alpha = 0.9f),
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Medium,
                )
            }
        }
        if (showGreeting && name != null) Spacer(Modifier.width(8.dp))
        Text(
            text = timeFormatter.format(currentTime),
            color = Color.White.copy(alpha = 0.86f),
            fontSize = 18.sp,
            fontWeight = FontWeight.Medium,
            modifier = Modifier
                .clip(RoundedCornerShape(8.dp))
                .background(MembySurface.copy(alpha = 0.70f))
                .padding(horizontal = 12.dp, vertical = 7.dp),
        )
    }
}

@Composable
private fun HomeArtworkPreloader(
    rows: List<HomeBrowseRow>,
    availableWidth: androidx.compose.ui.unit.Dp,
) {
    val context = LocalContext.current
    val density = LocalDensity.current
    val repo = ServiceLocator.repository
    val discovered = remember(rows) {
        // Warm the leading posters across several rows instead of exhausting the
        // budget on the first shelf. Vertical navigation is then far less likely to
        // compete with image fetch/decode as the next row enters the viewport.
        val perRow = rows.map { row ->
            row.items.take(4).map { row.kind to it }
        }
        buildList {
            val depth = perRow.maxOfOrNull { it.size } ?: 0
            for (itemIndex in 0 until depth) {
                perRow.forEach { candidates ->
                    candidates.getOrNull(itemIndex)?.let(::add)
                }
            }
        }
            .distinctBy { it.second.id }
            .take(24)
    }
    val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } }
    LaunchedEffect(signature, availableWidth) {
        // Let visible cards win the first network/decode slots, then warm everything
        // else that this home response discovered into Coil's memory and disk caches.
        kotlinx.coroutines.delay(350L)
        discovered.forEach { (kind, item) ->
            val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
            val width = if (landscape) {
                (availableWidth / 4.25f).coerceIn(184.dp, 316.dp)
            } else {
                (availableWidth / 6.8f).coerceIn(116.dp, 184.dp)
            }
            val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
            val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt()
            val url = if (landscape) {
                repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx)
            } else {
                repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
            } ?: return@forEach
            context.imageLoader.execute(
                ImageRequest.Builder(context)
                    .data(url)
                    .size(widthPx, heightPx)
                    .allowHardware(true)
                    .crossfade(false)
                    .build(),
            )
        }
    }
}

@Composable
private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
    val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
    BackdropLayer(item = focusedItem, modifier = Modifier.fillMaxSize())
}

@Composable
private fun FocusedHomeMetadata(
    homeViewModel: HomeViewModel,
    sectionLabel: String,
    modifier: Modifier = Modifier,
) {
    val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
    // Only the loading flags matter here, so take the content projection rather than the
    // whole state: this panel sits beside the hero and redraws on every focus change as
    // it is.
    val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
    MediaMetadataPanel(
        item = focusedItem,
        loading = homeContent.loading.isNotEmpty(),
        sectionLabel = sectionLabel,
        modifier = modifier,
    )
}

@Composable
private fun FocusedDetailsOverlay(
    homeViewModel: HomeViewModel,
    selected: BaseItem,
    restorePosition: Boolean,
    onPlay: (BaseItem) -> Unit,
    onToggleFavorite: (BaseItem, Boolean) -> Unit,
    isMyShow: Boolean,
    onToggleMyShow: (BaseItem, Boolean) -> Unit,
    onTogglePlayed: (BaseItem, Boolean) -> Unit,
    onClose: () -> Unit,
    onOpenItem: (BaseItem) -> Unit,
    airingNotice: AiringNotice? = null,
) {
    val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
    val item = focusedItem?.takeIf { it.id == selected.id } ?: selected
    if (item.isSeries) {
        SeriesDetailsOverlay(
            item = item,
            onPlay = onPlay,
            onToggleFavorite = onToggleFavorite,
            isMyShow = isMyShow,
            onToggleMyShow = onToggleMyShow,
            onClose = onClose,
            onOpenItem = onOpenItem,
            restorePosition = restorePosition,
            airingNotice = airingNotice,
        )
    } else if (item.isEpisode) {
        // An episode arrives here from Continue Watching, where it *is* the thing the
        // viewer chose. It used to open the movie page, which named the
        // episode with no way to tell which one it was or where in the show it sat.
        EpisodeDetailsOverlay(
            item = item,
            onPlay = onPlay,
            onToggleFavorite = onToggleFavorite,
            onTogglePlayed = onTogglePlayed,
            onClose = onClose,
            onOpenItem = onOpenItem,
            restorePosition = restorePosition,
        )
    } else {
        MediaDetailsOverlay(
            item = item,
            onPlay = onPlay,
            onToggleFavorite = onToggleFavorite,
            onTogglePlayed = onTogglePlayed,
            onClose = onClose,
            onOpenItem = onOpenItem,
            restorePosition = restorePosition,
        )
    }
}

@Composable
private fun FocusedQuickActionsOverlay(
    homeViewModel: HomeViewModel,
    selected: BaseItem,
    onOpenDetails: (BaseItem) -> Unit,
    onSetFavorite: (BaseItem, Boolean) -> Unit,
    onSetPlayed: (BaseItem, Boolean) -> Unit,
    onRemoveFromContinueWatching: (() -> Unit)?,
    rowTitle: String?,
    rowPinned: Boolean,
    onToggleRowPinned: (() -> Unit)?,
    onHideRow: (() -> Unit)?,
    onMoveRow: ((Int) -> Unit)?,
    onClose: () -> Unit,
) {
    val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
    MediaQuickActionsOverlay(
        item = focusedItem?.takeIf { it.id == selected.id } ?: selected,
        onOpenDetails = onOpenDetails,
        onSetFavorite = onSetFavorite,
        onSetPlayed = onSetPlayed,
        onRemoveFromContinueWatching = onRemoveFromContinueWatching,
        rowTitle = rowTitle,
        rowPinned = rowPinned,
        onToggleRowPinned = onToggleRowPinned,
        onHideRow = onHideRow,
        onMoveRow = onMoveRow,
        onClose = onClose,
    )
}

/**
 * Maps the gateway's rows onto the TV's row model.
 *
 * The user's section preferences still apply to the four fixed rows — turning off
 * "Favourites" must keep working — but anything the server invented (recommendations,
 * and whatever it grows next) is always shown, since the user never opted out of a row
 * that did not exist when they last opened Settings.
 */
internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBrowseRow> {
    val enabledSections = settings.homeSections.split(',').map(String::trim).toSet()
    val stillLoading = state.loading.isNotEmpty()

    val mapped = state.rows
        .foldNextUpIntoContinue()
        .filter { row ->
            when (row.kind) {
                "continue", "nextup" -> "continue" in enabledSections
                "favorites" -> "favorites" in enabledSections
                "latest" -> "latest" in enabledSections
                // The hero row is the four featured cards. It is drawn above the shelves
                // by HomeMovieHero, so letting it through here would print the same four
                // titles a second time as an unnamed row of posters directly beneath it.
                SERVER_HERO_ROW_KIND -> false
                else -> true
            }
        }
        .map { row ->
            HomeBrowseRow(
                id = row.id,
                title = if (row.kind == "favorites") {
                    personalisedFavouritesTitle(settings.username)
                } else {
                    row.title
                },
                items = if (row.kind == "schedule" || row.kind == "movie-schedule") {
                    row.items.sortedByScheduleDate()
                } else {
                    row.items
                },
                kind = when (row.kind) {
                    "continue" -> MediaRowKind.CONTINUE
                    "favorites" -> MediaRowKind.FAVORITES
                    "shows" -> MediaRowKind.SHOWS
                    "schedule" -> MediaRowKind.SHOWS
                    "movie-schedule" -> MediaRowKind.MOVIES
                    // Recommendation strips, "latest", and any kind a future server
                    // sends get poster cards, which suit mixed movie/series rows.
                    else -> MediaRowKind.MOVIES
                },
                loading = stillLoading && row.items.isEmpty(),
                emptyMessage = when (row.kind) {
                    "continue" -> "Nothing in progress"
                    "favorites" -> "Your favourites will appear here"
                    "latest" -> "No recent movies found"
                    "schedule" -> "No monitored shows are airing in the next 5 days"
                    "movie-schedule" -> "No monitored movies have a digital release in the next 5 days"
                    else -> "Nothing to show here yet"
                },
                showSecondaryMetadata = settings.showHomeCardMetadata,
            )
        }
    return applyHomeRowPreferences(mapped, settings)
}

/**
 * Continue Watching and Next Up are one row. This gateway no longer sends a `nextup` row,
 * but two things still do: the home cache written by the previous build, which is what a
 * TV draws before its first refresh lands, and a gateway that has not been deployed yet.
 * Its episodes belong on the end of Continue Watching rather than in a row of their own.
 */
internal fun List<HomeRow>.foldNextUpIntoContinue(): List<HomeRow> {
    val nextUp = filter { it.kind == "nextup" }.flatMap(HomeRow::items)
    if (nextUp.isEmpty()) return filterNot { it.kind == "nextup" }
    val seenSeries = firstOrNull { it.kind == "continue" }
        ?.items
        .orEmpty()
        .mapNotNullTo(mutableSetOf()) { it.seriesId?.takeIf(String::isNotBlank) }
    return filterNot { it.kind == "nextup" }.map { row ->
        if (row.kind != "continue") {
            row
        } else {
            val unseen = nextUp.filterNot { item ->
                val series = item.seriesId.orEmpty()
                series.isNotBlank() && series in seenSeries
            }
            row.copy(items = (row.items + unseen).distinctBy(BaseItem::id))
        }
    }
}

/**
 * Schedule timestamps are server-authored RFC 3339 values in the configured local
 * timezone. Sorting their fixed-width local representation keeps the nearest calendar
 * date first and also repairs an out-of-order cached payload from an older gateway.
 */
internal fun List<BaseItem>.sortedByScheduleDate(): List<BaseItem> =
    sortedWith(
        compareBy<BaseItem> { it.membyAirsAt.isNullOrBlank() }
            .thenBy { it.membyAirsAt.orEmpty() },
    )

internal fun String.decodeRowIds(): List<String> =
    lineSequence().map(String::trim).filter(String::isNotEmpty).distinct().toList()

internal fun applyHomeRowPreferences(
    rows: List<HomeBrowseRow>,
    settings: Settings,
): List<HomeBrowseRow> {
    val hidden = settings.homeHiddenRows.decodeRowIds().toSet()
    val pinned = settings.homePinnedRows.decodeRowIds().toSet()
    val order = settings.homeRowOrder.decodeRowIds().withIndex().associate { it.value to it.index }
    return rows
        .filterNot { it.id in hidden }
        .withIndex()
        .sortedWith(
            compareBy<IndexedValue<HomeBrowseRow>>(
                { if (it.value.id in pinned) 0 else 1 },
                { order[it.value.id] ?: Int.MAX_VALUE },
                { it.index },
            ),
        )
        .map(IndexedValue<HomeBrowseRow>::value)
}

internal fun homeRowsFor(
    destination: BrowseDestination,
    state: HomeUiState,
    settings: Settings,
): List<HomeBrowseRow> {
    val continueRow = HomeBrowseRow(
        id = "continue",
        title = "Continue Watching",
        items = state.continueWatching,
        kind = MediaRowKind.CONTINUE,
        loading = HomeSection.CONTINUE in state.loading,
        emptyMessage = "Nothing in progress",
        showSecondaryMetadata = settings.showHomeCardMetadata,
    )
    val latestMovies = HomeBrowseRow(
        id = "latest-movies",
        title = "Recently Added Movies",
        items = state.latestMovies,
        kind = MediaRowKind.MOVIES,
        loading = HomeSection.LATEST in state.loading,
        emptyMessage = "No recent movies found",
        showSecondaryMetadata = settings.showHomeCardMetadata,
    )
    val favorites = HomeBrowseRow(
        id = "favorites",
        title = personalisedFavouritesTitle(settings.username),
        items = state.favorites,
        kind = MediaRowKind.FAVORITES,
        loading = HomeSection.FAVORITES in state.loading,
        emptyMessage = "Your favourites will appear here",
        showSecondaryMetadata = settings.showHomeCardMetadata,
    )
    val destinationRows = when (destination) {
        // The gateway composes the home screen — including rows this app has no concept
        // of, like "Because you watched …" — so when it sends rows, they win.
        BrowseDestination.HOME -> if (state.rows.isNotEmpty()) {
            serverHomeRows(state, settings)
                // Genre/studio shelves have dedicated Movies and TV destinations. Home
                // keeps personalised, current and new-release rows so each screen has a
                // genuinely different browsing character.
                .filterNot { it.id.startsWith("curated:") }
        } else {
            settings.homeSections
                .split(',')
                .map(String::trim)
                .distinct()
                .flatMap { section ->
                    when (section) {
                        "continue" -> listOf(continueRow)
                        "latest" -> listOf(latestMovies)
                        "favorites" -> listOf(favorites)
                        else -> emptyList()
                    }
                }
        }
        BrowseDestination.MOVIES -> serverHomeRows(state, settings)
            // Movie discovery is authored by the server from this profile's genre and
            // studio affinity. Broad latest/library dumps belong on Home, not here.
            .filter { row ->
                row.kind == MediaRowKind.MOVIES &&
                    row.id.startsWith("curated:movies:")
            }
            .distinctBy(HomeBrowseRow::id) + listOf(
                favorites.copy(
                    id = "favourite-movies",
                    title = "Favourite Movies",
                    items = state.favorites.filter(BaseItem::isMovie),
                ),
            )
        BrowseDestination.SHOWS -> listOf(
            // The merged row, narrowed to television: what is part-way through and what
            // comes next are the same shelf here too.
            continueRow.copy(
                id = "continue-shows",
                items = state.continueWatching.filter(BaseItem::isEpisode),
            ),
            favorites.copy(
                id = "favourite-shows",
                title = "Favourite Shows",
                items = state.favorites.filter { it.isSeries || it.isEpisode },
            ),
        ) + serverHomeRows(state, settings)
            // These shelves are authored and ordered by the per-user recommendation
            // engine. Keep their server order: it is the user's affinity ranking.
            .filter { row ->
                row.kind == MediaRowKind.SHOWS && row.id.startsWith("curated:")
            }
            .distinctBy(HomeBrowseRow::id)
        BrowseDestination.FAVORITES -> listOf(favorites)
        BrowseDestination.FOR_YOU -> emptyList()
        // Search draws its own pane; the rail destinations that open an overlay have no
        // rows of their own either.
        BrowseDestination.SEARCH -> emptyList()
        BrowseDestination.CALENDAR -> emptyList()
        BrowseDestination.PROFILES -> emptyList()
        BrowseDestination.SETTINGS -> emptyList()
    }
    val deduplicated = deduplicateBrowseRows(destinationRows)
    return if (destination == BrowseDestination.HOME && state.rows.isEmpty()) {
        applyHomeRowPreferences(deduplicated, settings)
    } else {
        deduplicated
    }
}

/**
 * A card gets one place on a screen. Episodes collapse to their parent series so a show
 * in Continue Watching cannot immediately reappear as a genre recommendation, and live
 * UserData is a final guard against a stale recommendation cache showing something seen.
 */
internal fun deduplicateBrowseRows(rows: List<HomeBrowseRow>): List<HomeBrowseRow> {
    val used = mutableSetOf<String>()
    return rows.map { row ->
        val discovery = row.id.startsWith("curated:") ||
            row.id.startsWith("similar:") ||
            row.id.startsWith("for-you:") ||
            row.id == "recommended"
        val items = row.items.filter { item ->
            if (discovery && (
                    item.userData?.played == true ||
                        (item.userData?.playbackPositionTicks ?: 0L) > 0L
                    )
            ) {
                return@filter false
            }
            used.add(browseDeduplicationKey(item))
        }
        row.copy(items = items)
    }
}

/** Applies the opt-in watched-content policy after every destination has built its rows. */
internal fun applyWatchedVisibility(
    rows: List<HomeBrowseRow>,
    enabled: Boolean,
): List<HomeBrowseRow> = rows.map { row ->
    row.copy(
        items = visibleWithWatchedPreference(row.items, enabled),
        showWatchedEpisodeCount = enabled,
    )
}

internal fun visibleWithWatchedPreference(
    items: List<BaseItem>,
    enabled: Boolean,
): List<BaseItem> = if (enabled) {
    items.filterNot { item -> item.isMovie && item.userData?.played == true }
} else {
    items
}

private fun browseDeduplicationKey(item: BaseItem): String {
    item.seriesId?.trim()?.takeIf(String::isNotEmpty)?.let { return "series:$it" }
    if (item.isSeries) return "series:${item.id}"
    return "item:${item.id}"
}

internal fun forYouBrowseRows(
    state: ForYouUiState,
): List<HomeBrowseRow> {
    if (state.rows.isEmpty()) {
        return listOf(
            HomeBrowseRow(
                id = "for-you:picks",
                title = "Top picks for you",
                items = emptyList(),
                kind = MediaRowKind.MOVIES,
                loading = state.loading,
                emptyMessage = state.error ?: "Watch and browse a little more to shape your picks",
                showSecondaryMetadata = true,
            ),
        )
    }
    return state.rows.map { row ->
        HomeBrowseRow(
            id = row.id,
            title = row.title,
            items = row.items,
            kind = MediaRowKind.MOVIES,
            loading = state.loading && row.items.isEmpty(),
            emptyMessage = state.error ?: "No picks fit this time window yet",
            // Reasons are the primary secondary metadata in this destination.
            showSecondaryMetadata = true,
        )
    }
}

@Composable
private fun ForYouTimeBudget(
    selectedMinutes: Int,
    loading: Boolean,
    error: String?,
    onSelected: (Int) -> Unit,
) {
    Column(
        modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp, vertical = 4.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Text(
            "How much time do you have?",
            color = MembyMutedText,
            fontSize = 15.sp,
            fontWeight = FontWeight.SemiBold,
        )
        Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
            listOf(30 to "30 min", 60 to "1 hour", 120 to "2 hours", 0 to "Any length")
                .forEach { (minutes, label) ->
                    MembyChoiceChip(
                        label = label,
                        selected = minutes == selectedMinutes,
                        onClick = { if (!loading && minutes != selectedMinutes) onSelected(minutes) },
                    )
                }
        }
        error?.let {
            Text(it, color = Color(0xFFFFB454), fontSize = 13.sp)
        }
    }
}

@Composable
private fun ForYouNudgeBanner(
    visible: Boolean,
    username: String?,
    modifier: Modifier = Modifier,
) {
    val name = friendlyProfileName(username)
    androidx.compose.animation.AnimatedVisibility(
        visible = visible,
        enter = androidx.compose.animation.fadeIn(tween(220)),
        exit = androidx.compose.animation.fadeOut(tween(260)),
        modifier = modifier.zIndex(7f),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(92.dp)
                .background(
                    Brush.horizontalGradient(
                        0f to MembySplashTint,
                        0.62f to MembySplashTint.copy(alpha = 0.95f),
                        1f to MembySurfaceRaised.copy(alpha = 0.88f),
                    ),
                )
                // Keep copy inside television overscan and clear of the collapsed rail.
                .padding(start = 72.dp, end = 48.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                Modifier
                    .size(10.dp)
                    .clip(CircleShape)
                    .background(MembyAccent),
            )
            Spacer(Modifier.width(16.dp))
            Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
                Text(
                    "FOR YOU",
                    color = MembyAccentBright,
                    fontSize = 13.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 1.4.sp,
                )
                Text(
                    text = if (name == null) {
                        "Your personalised picks are ready"
                    } else {
                        "$name, your personalised picks are ready"
                    },
                    color = MembyOnSurface,
                    fontSize = 21.sp,
                    fontWeight = FontWeight.SemiBold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
                Text(
                    "Open For You from the menu and choose how much time you have.",
                    color = MembyMutedText,
                    fontSize = 15.sp,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

/**
 * Turns Emby account-style usernames into a friendlier home-screen name.
 *
 * Household accounts commonly use a trailing capital as a disambiguating surname
 * initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary
 * usernames such as PJ, CHRIS, alice, or MattCohen are left alone.
 */
internal fun friendlyProfileName(username: String?): String? {
    val name = username?.trim().orEmpty()
    if (name.isEmpty()) return null
    return if (
        name.length >= 3 &&
        name.last().isUpperCase() &&
        name[name.lastIndex - 1].isLowerCase()
    ) {
        name.dropLast(1)
    } else {
        name
    }
}

internal fun personalisedFavouritesTitle(username: String?): String {
    val name = friendlyProfileName(username) ?: return "Favourites"
    val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's"
    return "$possessive Favourites"
}

@Composable
private fun HeaderAction(label: String, onClick: () -> Unit) {
    var focused by remember { mutableStateOf(false) }
    val textColor by animateColorAsState(
        targetValue = if (focused) Color.White else MembyMutedText,
        animationSpec = tween(100),
        label = "header-action-text",
    )
    val backgroundColor by animateColorAsState(
        targetValue = if (focused) Color.White.copy(alpha = 0.10f) else Color.Transparent,
        animationSpec = tween(100),
        label = "header-action-background",
    )
    Text(
        text = label,
        color = textColor,
        fontSize = 14.sp,
        fontWeight = FontWeight.Medium,
        modifier = Modifier
            .border(
                width = if (focused) 1.dp else 0.dp,
                color = if (focused) Color.White else Color.Transparent,
                shape = RoundedCornerShape(6.dp),
            )
            .background(backgroundColor, RoundedCornerShape(6.dp))
            .onFocusChanged { focused = it.isFocused }
            .clickable(onClick = onClick)
            .padding(horizontal = 14.dp, vertical = 8.dp),
    )
}

@Composable
private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) {
    val repo = ServiceLocator.repository
    val imageUrl = repo.backdropUrl(item, maxWidth = 640) ?: repo.primaryUrl(item)
    Card(onClick = onClick, modifier = Modifier.width(320.dp)) {
        Column {
            Box(Modifier.width(320.dp).height(180.dp).background(MembyControlSurface)) {
                if (imageUrl != null) {
                    AsyncImage(
                        model = imageUrl,
                        contentDescription = item.name,
                        contentScale = ContentScale.Crop,
                        modifier = Modifier.fillMaxSize(),
                    )
                }
            }
            Text(
                text = item.name,
                color = Color.White,
                fontSize = 16.sp,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.padding(10.dp),
            )
        }
    }
}

@Composable
internal fun TvTextField(
    label: String,
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    isPassword: Boolean = false,
    keyboardType: KeyboardType = KeyboardType.Text,
    focusRequester: FocusRequester? = null,
    downFocusRequester: FocusRequester? = null,
    onNext: (() -> Unit)? = null,
    onDone: (() -> Unit)? = null,
) {
    var focused by remember { mutableStateOf(false) }
    val borderColor by animateColorAsState(
        targetValue = if (focused) MembyAccentBright else MembyOutline,
        animationSpec = tween(120),
        label = "login-field-border",
    )
    val backgroundColor by animateColorAsState(
        targetValue = if (focused) MembyAccentMuted else MembySurfaceRaised,
        animationSpec = tween(120),
        label = "login-field-background",
    )
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        Text(
            text = if (focused) "$label — selected" else label,
            color = if (focused) MembyAccentBright else MembyQuietText,
            fontSize = 15.sp,
            fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
        )
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .border(if (focused) 3.dp else 1.dp, borderColor, RoundedCornerShape(10.dp))
                .background(backgroundColor, RoundedCornerShape(10.dp))
                .padding(horizontal = 18.dp, vertical = 16.dp),
        ) {
            BasicTextField(
                value = value,
                onValueChange = onValueChange,
                singleLine = true,
                textStyle = TextStyle(color = Color.White, fontSize = 20.sp),
                cursorBrush = SolidColor(MembyAccentBright),
                visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
                keyboardOptions = KeyboardOptions(
                    keyboardType = if (isPassword) KeyboardType.Password else keyboardType,
                    imeAction = if (onNext != null) ImeAction.Next else ImeAction.Done,
                ),
                keyboardActions = KeyboardActions(
                    onNext = { onNext?.invoke() },
                    onDone = { onDone?.invoke() },
                ),
                modifier = Modifier
                    .fillMaxWidth()
                    .then(
                        if (downFocusRequester != null) {
                            Modifier.focusProperties { down = downFocusRequester }
                        } else {
                            Modifier
                        },
                    )
                    .then(
                        if (focusRequester != null) Modifier.focusRequester(focusRequester)
                        else Modifier,
                    )
                    .onFocusChanged { focused = it.isFocused },
            )
        }
    }
}

/** Requests the first focus target that is both attached and willing to accept focus. */
private fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean {
    requesters.forEach { requester ->
        if (runCatching { requester.requestFocus() }.isSuccess) return true
    }
    return false
}

private fun openScreensaverSettings(context: android.content.Context) {
    val candidates = listOf(
        AndroidSettings.ACTION_DREAM_SETTINGS,
        AndroidSettings.ACTION_DISPLAY_SETTINGS,
        AndroidSettings.ACTION_SETTINGS,
    )
    for (action in candidates) {
        val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        if (intent.resolveActivity(context.packageManager) != null) {
            context.startActivity(intent)
            return
        }
    }
}
