EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

WelcomeScreen.kt

package dev.stave.ui.welcome

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.R
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical
import dev.stave.ui.theme.isCompactHeight
import dev.stave.ui.theme.isCompactWidth
import dev.stave.ui.theme.withInterDigits

/**
 * Landing page when no UPnP server has been picked yet. Gives the user two clear paths
 * forward — direct to picker (fastest for first-run) or via Settings (if they want to
 * explore other options first). Without a focusable button here the screen is a dead
 * end on TV where there's no cursor/touch to fall back on.
 */
@Composable
fun WelcomeScreen(
    onPickServer: () -> Unit,
    onOpenSettings: () -> Unit,
    // Maintainer, 2026-10-29: way into Cast/Observe from Welcome; button
    // shown only when the caller wires it.
    onOpenCast: (() -> Unit)? = null,
    // Maintainer, 2026-10-29: false while startup is still deciding whether
    // to auto-navigate a returning user to Browse, so Welcome doesn't flash.
    ready: Boolean = true,
) {
    // Maintainer, 2026-10-29: not ready yet — render nothing; the NavHost's
    // background shows through. Returning before the focus effect so it
    // runs (and focuses the primary button) once the screen is ready.
    if (!ready) return
    val primaryFocus = remember { FocusRequester() }
    // Auto-focus the primary action on open. Wrapped so a detached node doesn't crash.
    LaunchedEffect(Unit) {
        runCatching { primaryFocus.requestFocus() }
    }

    Box(
        Modifier
            .fillMaxSize()
            .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
        contentAlignment = Alignment.Center,
    ) {
        // Maintainer, 2026-10-29: scrolls, and the badge shrinks on short
        // screens — in phone landscape the column was taller than the
        // screen and the buttons got zero height. TV (never compact
        // height) keeps the 200dp badge and fits without scrolling.
        Column(
            modifier = Modifier.verticalScroll(rememberScrollState()),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Image(
                painter = painterResource(id = R.drawable.earhorn_badge),
                contentDescription = null,
                modifier = Modifier.size(if (isCompactHeight()) 96.dp else 200.dp),
            )
            Spacer(Modifier.height(8.dp))
            Text(
                "Welcome to Earhorn",
                style = MaterialTheme.typography.displaySmall,
                color = OnPrimary,
            )
            // Maintainer, 2026-10-29: copy names all three ways in (server,
            // this device, Cast) instead of only the server.
            Text(
                "Pick a UPnP/DLNA library on your network, play music stored on this device, or follow a Cast speaker.",
                style = MaterialTheme.typography.titleMedium,
                color = OnMuted,
            )

            Spacer(Modifier.height(24.dp))

            // Maintainer, 2026-10-29: the Cast button sits in the button row
            // where there is width for three; on phone-portrait widths the
            // row is already nearly full, so it goes on its own line below.
            val castInRow = !isCompactWidth()
            Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
                PrimaryButton(
                    label = "Find my server",
                    focusRequester = primaryFocus,
                    onClick = onPickServer,
                )
                SecondaryButton(
                    label = "Settings",
                    onClick = onOpenSettings,
                )
                if (onOpenCast != null && castInRow) {
                    SecondaryButton(
                        label = "Cast / Observe",
                        onClick = onOpenCast,
                    )
                }
            }
            if (onOpenCast != null && !castInRow) {
                SecondaryButton(
                    label = "Cast / Observe",
                    onClick = onOpenCast,
                )
            }
        }
    }
}

@Composable
private fun PrimaryButton(
    label: String,
    focusRequester: FocusRequester,
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    val shape = RoundedCornerShape(6.dp)
    Box(
        Modifier
            .background(AccentAmber, shape)
            .focusRequester(focusRequester)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 28.dp, vertical = 14.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnAmber)
    }
}

@Composable
private fun SecondaryButton(label: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val shape = RoundedCornerShape(6.dp)
    Box(
        Modifier
            .background(SurfaceRaised, shape)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 28.dp, vertical = 14.dp),
        contentAlignment = Alignment.Center,
    ) {
        Text(withInterDigits(label), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
    }
}