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

LocalHubScreen.kt

package dev.stave.ui.local

// Maintainer, 2026-10-29: imports for the permanent-denial → app settings path and the
// ON_RESUME permission re-check.
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.ui.components.staveFocusable
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
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
// Maintainer, 2026-10-29: HubContent vertical scroll.
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll

/**
 * Hub screen for on-device ("This Device") music. Sits at the entry point of the
 * local-music browse tree: shows summary counts, exposes a "Shuffle All" action, and
 * provides tiles to descend into Albums / Artists / Songs.
 *
 * Permission gating: if READ_MEDIA_AUDIO (or READ_EXTERNAL_STORAGE on API <33) is
 * not yet granted, the screen renders a permission-request UI instead of the hub.
 * Once granted, [LocalLibraryViewModel.refreshPermissionState] reloads the summary.
 */
@Composable
fun LocalHubScreen(
    onOpenAlbums: () -> Unit,
    onOpenArtists: () -> Unit,
    onOpenSongs: () -> Unit,
    onBack: () -> Unit,
    viewModel: LocalLibraryViewModel = hiltViewModel(),
) {
    val state by viewModel.hubState.collectAsState()

    // Maintainer, 2026-10-29: hosting Activity, unwrapped from LocalContext the same way
    // StaveNavHost does — needed for shouldShowRequestPermissionRationale and lifecycle.
    val context = LocalContext.current
    val activity = remember(context) {
        run {
            var ctx: Context? = context
            while (ctx is ContextWrapper) {
                if (ctx is Activity) return@run ctx
                ctx = ctx.baseContext
            }
            null
        }
    }
    // Maintainer, 2026-10-29: after a permanent denial the system dialog never shows again,
    // so "Grant permission" did nothing. Track it; the button then opens app settings.
    var permanentlyDenied by rememberSaveable { mutableStateOf(false) }

    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        // Maintainer, 2026-10-29: denied + no rationale = permanently denied.
        permanentlyDenied = !granted && activity != null &&
            !ActivityCompat.shouldShowRequestPermissionRationale(activity, viewModel.permissionName)
        viewModel.refreshPermissionState()
    }

    // Refresh permission state on each recomposition initiated by navigation —
    // covers the case where the user grants permission via system Settings and
    // returns to the app rather than tapping our in-app request button.
    LaunchedEffect(Unit) { viewModel.refreshPermissionState() }

    // Maintainer, 2026-10-29: re-check on every ON_RESUME too, so a grant (or revoke) made
    // in system Settings is noticed when the user comes back, without re-entering the
    // screen. Only rescans when the state actually needs it (no full rescan per resume).
    DisposableEffect(activity) {
        val lifecycle = (activity as? LifecycleOwner)?.lifecycle
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME && (
                    !viewModel.hasPermission() ||
                        viewModel.hubState.value is LocalLibraryViewModel.HubState.PermissionRequired
                    )
            ) {
                viewModel.refreshPermissionState()
            }
        }
        lifecycle?.addObserver(observer)
        onDispose { lifecycle?.removeObserver(observer) }
    }

    Column(
        Modifier
            .fillMaxSize()
            .padding(
                horizontal = adaptiveScreenPaddingHorizontal(),
                vertical = adaptiveScreenPaddingVertical(),
            )
    ) {
        // Header
        if (isCompactWidth()) {
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Column(Modifier.weight(1f)) {
                    Text(
                        "On this device",
                        style = MaterialTheme.typography.headlineMedium,
                        color = OnPrimary
                    )
                    Text(
                        "Music files stored locally",
                        style = MaterialTheme.typography.bodyMedium,
                        color = OnMuted
                    )
                }
                HubAction(label = "Back", onClick = onBack)
            }
        } else {
            Row(
                Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.Bottom
            ) {
                Column(Modifier.weight(1f)) {
                    Text(
                        "On this device",
                        style = MaterialTheme.typography.displayMedium,
                        color = OnPrimary
                    )
                    Text(
                        "Music files stored locally on this device",
                        style = MaterialTheme.typography.bodyLarge,
                        color = OnMuted
                    )
                }
                HubAction(label = "Back", onClick = onBack)
            }
        }

        // Maintainer, 2026-10-29: compact 24dp spacing on compact height too (phone landscape).
        Spacer(Modifier.height(if (isCompactWidth() || isCompactHeight()) 24.dp else 40.dp))

        when (val s = state) {
            is LocalLibraryViewModel.HubState.PermissionRequired -> {
                // Maintainer, 2026-10-29: permanently denied → open this app's system
                // settings page instead of a launcher call that silently does nothing.
                PermissionRequestState(
                    buttonLabel = if (permanentlyDenied) "Open settings" else "Grant permission",
                    onRequest = {
                        if (permanentlyDenied) {
                            runCatching {
                                val intent = Intent(
                                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                                    Uri.fromParts("package", context.packageName, null),
                                )
                                if (activity == null) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                                (activity ?: context).startActivity(intent)
                            }
                        } else {
                            permissionLauncher.launch(viewModel.permissionName)
                        }
                    }
                )
            }
            is LocalLibraryViewModel.HubState.Loading -> {
                Text("Scanning…", style = MaterialTheme.typography.bodyLarge, color = OnMuted)
            }
            is LocalLibraryViewModel.HubState.Ready -> {
                if (s.songCount == 0) {
                    EmptyLocalState()
                } else {
                    HubContent(
                        albumCount = s.albumCount,
                        artistCount = s.artistCount,
                        songCount = s.songCount,
                        onShuffleAll = { viewModel.shuffleAll() },
                        onOpenAlbums = onOpenAlbums,
                        onOpenArtists = onOpenArtists,
                        onOpenSongs = onOpenSongs,
                    )
                }
            }
        }
    }
}

@Composable
private fun HubContent(
    albumCount: Int,
    artistCount: Int,
    songCount: Int,
    onShuffleAll: () -> Unit,
    onOpenAlbums: () -> Unit,
    onOpenArtists: () -> Unit,
    onOpenSongs: () -> Unit,
) {
    // Maintainer, 2026-10-29: scrolls so the Artists/Songs tiles are reachable on phone
    // landscape (~245dp usable); no visible change where everything already fits (TV).
    Column(
        Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        // Primary action — Shuffle All. Most common car-use pattern.
        PrimaryAction(
            label = "Shuffle All",
            subtitle = "Play every song in random order",
            onClick = onShuffleAll,
        )

        Spacer(Modifier.height(8.dp))

        HubTile(
            title = "Albums",
            subtitle = "$albumCount album${if (albumCount == 1) "" else "s"}",
            onClick = onOpenAlbums,
        )
        HubTile(
            title = "Artists",
            subtitle = "$artistCount artist${if (artistCount == 1) "" else "s"}",
            onClick = onOpenArtists,
        )
        HubTile(
            title = "Songs",
            subtitle = "$songCount track${if (songCount == 1) "" else "s"}",
            onClick = onOpenSongs,
        )
    }
}

@Composable
private fun PrimaryAction(label: String, subtitle: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .fillMaxWidth()
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 12.dp, vertical = 10.dp)
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Column(Modifier.weight(1f)) {
                Text(withInterDigits(label), style = MaterialTheme.typography.titleLarge, color = OnPrimary)
                Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodyMedium, color = OnMuted)
            }
            Text("\u25b8", style = MaterialTheme.typography.titleLarge, color = OnQuiet)
        }
    }
}

@Composable
private fun HubTile(title: String, subtitle: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .fillMaxWidth()
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 12.dp, vertical = 8.dp)
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Column(Modifier.weight(1f)) {
                Text(withInterDigits(title), style = MaterialTheme.typography.titleMedium, color = OnPrimary)
                Text(withInterDigits(subtitle), style = MaterialTheme.typography.bodySmall, color = OnMuted)
            }
            Text("Open \u25b8", style = MaterialTheme.typography.labelMedium, color = OnQuiet)
        }
    }
}

@Composable
private fun HubAction(label: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    Box(
        Modifier
            .background(SurfaceRaised)
            .staveFocusable(interaction, cornerRadiusDp = 6)
            .clickable(interaction, indication = null) { onClick() }
            .padding(horizontal = 20.dp, vertical = 10.dp)
    ) {
        Text(label, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
    }
}

@Composable
// Maintainer, 2026-10-29: buttonLabel added ("Grant permission" / "Open settings").
private fun PermissionRequestState(buttonLabel: String, onRequest: () -> Unit) {
    Column(
        Modifier.fillMaxWidth().padding(vertical = 40.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Permission needed", style = MaterialTheme.typography.headlineMedium, color = OnPrimary)
        Spacer(Modifier.height(12.dp))
        Text(
            "Earhorn needs permission to read audio files on this device " +
                "to show your local music.",
            style = MaterialTheme.typography.bodyLarge,
            color = OnMuted,
        )
        Spacer(Modifier.height(24.dp))
        val interaction = remember { MutableInteractionSource() }
        Box(
            Modifier
                .background(SurfaceRaised)
                .staveFocusable(interaction, cornerRadiusDp = 6)
                .clickable(interaction, indication = null) { onRequest() }
                .padding(horizontal = 32.dp, vertical = 14.dp)
        ) {
            Text(buttonLabel, style = MaterialTheme.typography.labelLarge, color = OnPrimary)
        }
    }
}

@Composable
private fun EmptyLocalState() {
    Column(
        Modifier.fillMaxWidth().padding(vertical = 40.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("No music found", style = MaterialTheme.typography.headlineMedium, color = OnPrimary)
        Spacer(Modifier.height(12.dp))
        Text(
            "There are no audio files on this device. Connect a UPnP server " +
                "to play music from your library, or copy audio files to this device's storage.",
            style = MaterialTheme.typography.bodyLarge,
            color = OnMuted,
        )
    }
}