apps\player\src\main\java\cn\nfarms\player\ui\NowPlayingScreen.kt¶
源码路径:
apps\player\src\main\java\cn\nfarms\player\ui\NowPlayingScreen.kt· 219 行
package cn.nfarms.player.ui
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
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.Slider
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.duration
import cn.nfarms.player.PlayerUi
import cn.nfarms.player.PlayerViewModel
/**
* 正在播放页。
*
* 歌词跟随的判定:**取「最后一条时间 <= 当前进度」**,不是「第一条 >= 当前」。
* 后者会出现「歌词总是慢一句」的观感,因为一条歌词在它自己的时间点上就该亮了。
*/
@Composable
fun NowPlayingScreen(ui: PlayerUi, vm: PlayerViewModel) {
val t = ui.current
var note by remember { mutableStateOf("") }
var dragging by remember { mutableStateOf(false) }
var draftPos by remember { mutableStateOf(0f) }
if (t == null) {
EmptyState("还没选曲目", "去「曲库」点一首;点过之后这里会显示进度、歌词与书签")
return
}
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
CardBox {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(t.title, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(
"${t.artist.ifBlank { "未知艺人" }} · ${t.album.ifBlank { "无专辑" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (ui.preparing) {
Pill("准备中", BrandWarn)
} else if (ui.playing) {
Pill("播放中", BrandOk)
} else {
Pill("暂停", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Spacer(Modifier.height(10.dp))
// 进度条:拖动时先显示草稿值,松手才 seek —— 否则会一直跟播放器打架
val posMs = if (dragging) draftPos.toLong() else ui.positionMs
Slider(
value = posMs.toFloat(),
onValueChange = { dragging = true; draftPos = it },
onValueChangeFinished = { dragging = false; vm.seekTo(draftPos.toLong()) },
valueRange = 0f..(ui.durationMs.coerceAtLeast(1)).toFloat(),
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(duration(posMs), style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.weight(1f))
Text(duration(ui.durationMs), style = MaterialTheme.typography.labelSmall)
}
Spacer(Modifier.height(6.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { vm.seekBy(-15_000) }) {
Icon(Icons.Filled.FastRewind, contentDescription = "后退 15 秒")
}
IconButton(onClick = { vm.prev() }) {
Icon(Icons.Filled.SkipPrevious, contentDescription = "上一首")
}
FilledIconButton(onClick = { vm.toggle() }, modifier = Modifier.width(56.dp)) {
Icon(Icons.Filled.PlayArrow, contentDescription = "播放/暂停")
}
IconButton(onClick = { vm.next() }) {
Icon(Icons.Filled.SkipNext, contentDescription = "下一首")
}
IconButton(onClick = { vm.seekBy(30_000) }) {
Icon(Icons.Filled.FastForward, contentDescription = "前进 30 秒")
}
}
}
CardBox {
Section("变速与续播", "速度会落盘 —— 下次打开还是这个速度")
// 用 FlowRow 而不是 Row:五个按钮在 1080px 上放不下,Row 会把最后一个
// 裁掉(之前 2.0x 就是这么消失的,而且**不报错**)。FlowRow 会自动换行。
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
androidx.compose.foundation.layout.FlowRow(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (s in listOf(0.75f, 1f, 1.25f, 1.5f, 2f)) {
if (ui.speed == s) {
Button(onClick = { vm.setSpeed(s) }) { Text("${s}x") }
} else {
OutlinedButton(onClick = { vm.setSpeed(s) }) { Text("${s}x") }
}
}
}
Spacer(Modifier.height(6.dp))
KV("上次进度", if (t.positionMs > 0) duration(t.positionMs) else "(没有)")
KV("进度会落在", "library.json · 每 5 秒一次 + 暂停/切曲时必写")
}
CardBox {
Section("歌词", "${ui.lyrics.size} 行 · 支持 [mm:ss] 与 [mm:ss.xx]")
if (ui.lyrics.isEmpty()) {
Text(
"这首歌没有歌词。放一个同名 .lrc 到音频旁边即可 —— " +
"演示曲自带一份(重启 App 或点「刷新」后可见)。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// 当前行:取**最后一条**时间 <= 进度的
val idx = ui.lyrics.indexOfLast { it.atMs <= ui.positionMs }
Column(Modifier.fillMaxWidth().heightIn(max = 260.dp)) {
for ((i, line) in ui.lyrics.withIndex()) {
val active = i == idx
Text(
line.text,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
color = if (active) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
CardBox {
Section("书签", "记下这一刻,之后一点就跳回来")
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("备注(可空)") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(onClick = {
vm.addBookmarkHere(note)
note = ""
}) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("加在此刻")
}
}
if (ui.bookmarks.isEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
"还没有书签。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}