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

MetadataScreen.kt

package dev.stave.ui.metadata

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
// Maintainer, 2026-10-29: focusable rows for D-pad.
import androidx.compose.foundation.focusable
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.foundation.lazy.LazyColumn
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.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import androidx.compose.foundation.interaction.MutableInteractionSource
import dev.stave.domain.model.DidlItem
import dev.stave.ui.components.staveFocusable
import dev.stave.domain.model.parseGenres
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.withInterDigits
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.upnp.AudioMetadataReader
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal
import dev.stave.ui.theme.adaptiveScreenPaddingVertical

sealed interface TagState {
    data object Idle : TagState
    data object Loading : TagState
    data class Loaded(val fields: Map<String, String>) : TagState
    data class Error(val message: String) : TagState
}

@HiltViewModel
class MetadataViewModel @Inject constructor(
    private val handoff: MetadataHandoff,
    private val reader: AudioMetadataReader,
) : ViewModel() {

    // Grab the pending track exactly once, then drop it so a stale reference can't leak
    // into a later unrelated open.
    val track: DidlItem.Track? = handoff.pending.also { handoff.pending = null }

    private val _tags = MutableStateFlow<TagState>(TagState.Idle)
    val tags: StateFlow<TagState> = _tags.asStateFlow()

    fun loadFileTags() {
        val url = track?.resourceUrl ?: return
        if (_tags.value !is TagState.Idle) return  // already loading / loaded / errored
        _tags.value = TagState.Loading
        viewModelScope.launch {
            val result = reader.readTags(url)
            _tags.value = if (result.isEmpty()) {
                TagState.Error("No embedded tags found.")
            } else {
                TagState.Loaded(result)
            }
        }
    }
}

@Composable
fun MetadataScreen(
    onBack: () -> Unit,
    viewModel: MetadataViewModel = hiltViewModel(),
) {
    BackHandler(onBack = onBack)
    val track = viewModel.track
    val tags by viewModel.tags.collectAsState()

    LaunchedEffect(track?.id) {
        if (track != null) viewModel.loadFileTags()
    }

    if (track == null) {
        Box(Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
            Text(
                "No track selected.",
                style = MaterialTheme.typography.headlineSmall,
                color = OnMuted,
            )
        }
        return
    }

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .padding(horizontal = adaptiveScreenPaddingHorizontal(), vertical = adaptiveScreenPaddingVertical()),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        item {
            Column {
                Text(
                    withInterDigits(track.title),
                    style = MaterialTheme.typography.displaySmall,
                    color = OnPrimary,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
                listOfNotNull(track.artist, track.album).joinToString(" · ").takeIf { it.isNotBlank() }?.let {
                    Text(withInterDigits(it), style = MaterialTheme.typography.titleMedium, color = OnMuted)
                }
                // Wide-touch layouts tighten the header gap — 20dp under a
                // two-line title burned a visible band of landscape height
                // (field-rejected). TV keeps the original spacing.
                val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
                val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
                    android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
                Spacer(Modifier.height(
                    if (dev.stave.ui.theme.isCompactWidth() || mdTv) 20.dp else 10.dp,
                ))
            }
        }

        item { SectionHeader("From the server") }
        items(upnpRows(track)) { (label, value) -> Row(label, value) }

        item {
            val mdCfg = androidx.compose.ui.platform.LocalConfiguration.current
            val mdTv = (mdCfg.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) ==
                android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
            Spacer(Modifier.height(
                if (dev.stave.ui.theme.isCompactWidth() || mdTv) 24.dp else 12.dp,
            ))
            SectionHeader("From the file")
        }
        when (val s = tags) {
            is TagState.Idle, is TagState.Loading -> item {
                Text(
                    "Reading tags…",
                    style = MaterialTheme.typography.bodyMedium,
                    color = OnQuiet,
                    modifier = Modifier.padding(start = 4.dp, top = 8.dp),
                )
            }
            is TagState.Error -> item {
                Text(
                    s.message,
                    style = MaterialTheme.typography.bodyMedium,
                    color = OnQuiet,
                    modifier = Modifier.padding(start = 4.dp, top = 8.dp),
                )
            }
            is TagState.Loaded -> items(s.fields.entries.toList()) { entry ->
                Row(entry.key, entry.value)
            }
        }
    }
}

private fun upnpRows(t: DidlItem.Track): List<Pair<String, String>> = buildList {
    t.artist?.let { add("Artist" to it) }
    t.albumArtist?.let { add("Album artist" to it) }
    t.composer?.let { add("Composer" to it) }
    t.album?.let { add("Album" to it) }
    run {
        val tokens = parseGenres(t.genre)
        if (tokens.isNotEmpty()) add("Genre" to tokens.joinToString(", "))
    }
    t.date?.let { add("Date" to it) }
    t.trackNumber?.let { add("Track #" to it.toString()) }
    t.discNumber?.let { add("Disc #" to it.toString()) }
    t.duration?.let { add("Duration" to formatDuration(it)) }
    t.mimeType?.let { add("MIME type" to it) }
    t.sampleFrequency?.let { add("Sample rate" to "${it / 1000.0} kHz") }
    t.bitsPerSample?.let { add("Bit depth" to "$it bit") }
    t.bitrate?.let { add("Bitrate" to "${it / 1000} kbps") }
    t.channels?.let { add("Channels" to it.toString()) }
    add("URL" to t.resourceUrl)
    // Everything else from extras that we didn't surface above
    t.extras.forEach { (k, v) ->
        if (v.isNotBlank()) add(k.removePrefix("upnp:").removePrefix("dc:") to v)
    }
}

private fun formatDuration(d: kotlin.time.Duration): String {
    val total = d.inWholeSeconds
    val m = total / 60
    val s = total % 60
    return "%d:%02d".format(m, s)
}

@Composable
private fun SectionHeader(title: String) {
    Column {
        Text(
            title,
            style = MaterialTheme.typography.titleMedium,
            color = OnPrimary,
        )
        Spacer(Modifier.height(4.dp))
        Box(
            Modifier
                .width(48.dp)
                .height(2.dp)
                .background(AccentAmber),
        )
        Spacer(Modifier.height(8.dp))
    }
}

@Composable
private fun Row(label: String, value: String) {
    val interaction = remember { MutableInteractionSource() }
    Row(
        Modifier
            .fillMaxWidth()
            // Maintainer, 2026-10-29: rows had staveFocusable (focus border only)
            // but nothing focusable, so the list had no D-pad targets and the
            // "From the file" tags below the fold were unreachable on TV.
            // focusable() feeds the same interaction source the border reads,
            // and brings the focused row into view so D-pad scrolls the list.
            .focusable(interactionSource = interaction)
            .staveFocusable(interaction)
            .padding(vertical = 3.dp),
    ) {
        Text(
            label,
            style = MaterialTheme.typography.bodySmall,
            color = OnQuiet,
            modifier = Modifier.width(180.dp),
        )
        Text(
            withInterDigits(value),
            style = MaterialTheme.typography.bodyMedium,
            color = OnMuted,
            modifier = Modifier.fillMaxWidth(),
        )
    }
}

// LazyListScope.items for Pair<String, String> and single-string lists.
// Wrapping in a small `items` helper so the when-branch above stays readable.
private fun androidx.compose.foundation.lazy.LazyListScope.items(
    list: List<Pair<String, String>>,
    content: @Composable (Pair<String, String>) -> Unit,
) {
    items(list.size) { i -> content(list[i]) }
}

@JvmName("itemsEntries")
private fun androidx.compose.foundation.lazy.LazyListScope.items(
    entries: List<Map.Entry<String, String>>,
    content: @Composable (Map.Entry<String, String>) -> Unit,
) {
    items(entries.size) { i -> content(entries[i]) }
}