跳转至

apps\player\src\main\java\cn\nfarms\player\ui\LibraryScreen.kt

源码路径:apps\player\src\main\java\cn\nfarms\player\ui\LibraryScreen.kt · 211 行

package cn.nfarms.player.ui

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cn.nfarms.core.ui.BrandErr
import cn.nfarms.core.ui.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.EmptyState
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.core.ui.ago
import cn.nfarms.core.ui.duration
import cn.nfarms.player.PlayerUi
import cn.nfarms.player.PlayerViewModel
import cn.nfarms.player.data.Track

/**
 * 曲库页。
 *
 * 「断点续播」在这里**必须看得见**:有进度的曲目要显示一个进度条 + 「继续」标,
 * 否则用户不知道上次听到哪儿、也不知道点了会从中间开始。
 */
@Composable
fun LibraryScreen(ui: PlayerUi, vm: PlayerViewModel) {
    var newPlaylist by remember { mutableStateOf("") }
    var showNew by remember { mutableStateOf(false) }

    LazyColumn(
        Modifier.fillMaxSize().padding(horizontal = 14.dp),
        contentPadding = PaddingValues(vertical = 12.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        item {
            CardBox {
                Section("曲库", "${ui.lib.tracks.size} 首 · 收藏 ${ui.lib.tracks.count { it.favorite }} 首")
                if (!ui.connected) {
                    Text("正在连播放服务…", style = MaterialTheme.typography.bodySmall, color = BrandWarn)
                }
                Spacer(Modifier.height(6.dp))
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    OutlinedButton(onClick = { vm.syncPlaylist() }) { Text("列表同步") }
                    OutlinedButton(onClick = { showNew = !showNew }) { Text("新建列表") }
                    OutlinedButton(onClick = { vm.refreshLibrary() }) { Text("刷新") }
                }
                if (showNew) {
                    Spacer(Modifier.height(6.dp))
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        OutlinedTextField(
                            value = newPlaylist,
                            onValueChange = { newPlaylist = it },
                            label = { Text("列表名") },
                            singleLine = true,
                            modifier = Modifier.weight(1f),
                        )
                        Spacer(Modifier.width(8.dp))
                        Button(
                            onClick = {
                                if (newPlaylist.isNotBlank()) {
                                    vm.createPlaylist(newPlaylist.trim())
                                    newPlaylist = ""
                                    showNew = false
                                }
                            },
                        ) { Text("建") }
                    }
                }
                if (ui.lib.lastSyncAt > 0) {
                    KV("上次同步", ago(ui.lib.lastSyncAt))
                }
            }
        }

        if (ui.lib.tracks.isEmpty()) {
            item {
                EmptyState(
                    "曲库是空的",
                    "首次启动会自动生成两首演示曲(纯代码合成的 WAV);" +
                        "也可以把 .wav/.mp3 放进 App 私有目录 nfarms/media/audio 再刷新",
                )
            }
        }

        items(ui.lib.tracks, key = { it.key }) { t -> TrackRow(t, ui, vm) }

        if (ui.playlists.isNotEmpty()) {
            item { Section("播放列表", "「列表同步」会把当前曲库增量并进「本地扫描」") }
            items(ui.playlists, key = { it.id }) { p ->
                CardBox {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Column(Modifier.weight(1f)) {
                            Text(p.name, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
                            Text(
                                "${p.keys.size} 首 · 更新于 ${ago(p.updatedAt)}",
                                style = MaterialTheme.typography.labelSmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
                            )
                        }
                        OutlinedButton(onClick = { vm.deletePlaylist(p.id) }) { Text("删除") }
                    }
                }
            }
        }
    }
}

@Composable
private fun TrackRow(t: Track, ui: PlayerUi, vm: PlayerViewModel) {
    val isCurrent = ui.current?.key == t.key
    CardBox {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Column(Modifier.weight(1f).clickable { vm.play(t) }) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(
                        t.title,
                        fontWeight = FontWeight.SemiBold,
                        fontSize = 14.sp,
                        color = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
                    )
                    if (t.encrypted) {
                        Spacer(Modifier.width(6.dp))
                        Pill("加密", BrandOk)
                    }
                    if (t.hasProgress) {
                        Spacer(Modifier.width(6.dp))
                        Pill("继续 ${duration(t.positionMs)}", BrandWarn)
                    }
                }
                Text(
                    buildString {
                        append(t.artist.ifBlank { "未知艺人" })
                        if (t.durationMs > 0) append(" · ${duration(t.durationMs)}")
                        if (t.sizeBytes > 0) append(" · ${t.sizeBytes / 1024} KB")
                    },
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                // 断点续播的可见证据:一条细进度条
                if (t.hasProgress && t.durationMs > 0) {
                    Spacer(Modifier.height(4.dp))
                    androidx.compose.material3.LinearProgressIndicator(
                        progress = { (t.positionMs.toFloat() / t.durationMs).coerceIn(0f, 1f) },
                        modifier = Modifier.fillMaxWidth(),
                    )
                }
            }
            IconButton(onClick = { vm.play(t) }) {
                Icon(Icons.Filled.PlayArrow, contentDescription = "播放")
            }
            IconButton(onClick = { vm.toggleFavorite(t.key) }) {
                Icon(
                    if (t.favorite) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
                    contentDescription = "收藏",
                    tint = if (t.favorite) BrandErr else MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            IconButton(onClick = { vm.remove(t.key) }) {
                Icon(Icons.Filled.Delete, contentDescription = "移除", modifier = Modifier.size(18.dp))
            }
        }
        Spacer(Modifier.height(2.dp))
        // 路径**从左边截**:文件名在末尾,是用户唯一能用来辨认的东西。
        // 之前用 `takeLast(48)` 截的是尾部,于是界面显示 "ser/0/cn.nfarms.player/…"
        // —— 恰好把文件名切掉了。
        Text(
            t.path.let { if (it.length <= 56) it else "…" + it.takeLast(55) },
            style = MaterialTheme.typography.labelSmall,
            fontFamily = FontFamily.Monospace,
            color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
            fontSize = 9.sp,
        )
    }
}