最近在做一个 Compose 小项目时,手痒临时加了一个“仿网易云播放页”的功能,主要包含两个核心效果:
- 黑胶唱片旋转 + 唱针动画
- 点击切换歌词页 + 歌词自动滚动高亮
这篇文章就记录一下完整实现思路,以及实现过程中遇到的一些细节坑。
Demo效果图
技术栈选型
老实说这类界面用传统的 View 布局也能实现,但这次我想用 Compose 的声明式 UI 来试试。
搭配:
- Jetpack Compose 渲染 UI
- ExoPlayer 播放音频
- Raw 资源文件存放音乐和
.lrc歌词 LaunchedEffect+remember管理动画和播放状态
页面结构大致规划
跟网易云差不多,分成几块:
-
模糊背景:用封面图铺底,
Modifier.blur()+ 半透明遮罩 -
顶部按钮:返回、分享
-
中间区域:
- 默认显示唱片 + 唱针
- 点击切换到歌词列表
-
底部操作区:歌曲信息、点赞评论、进度条、播放控制
这样用一个 Box 根布局,把背景、前景分层加进去。
播放器 & 歌曲数据
播放用的是 ExoPlayer,我们把每首歌的封面、音频 raw 资源、歌词 raw 资源提前写进一个 Song 数据类,然后用 rememberExoPlayerMutable 保持每次切歌都能创建新的播放器并释放旧的:
Kotlin
val exoPlayer = rememberExoPlayerMutable(context, curSong.rawRes)
val isPlaying = remember { mutableStateOf(false) }
注意:MediaItem.fromUri("rawresource://...") 这种 URI 格式可以直接播放 raw 里的文件。
黑胶唱片旋转 & 唱针摆动
这部分是最有趣的。
旋转黑胶
Compose 没有“无限旋转”的现成组件,但可以自己控制一个 rotationZ 值,每帧递增:
Kotlin
var diskRotation by remember { mutableFloatStateOf(0f) }
LaunchedEffect(isPlaying.value) {
while (isPlaying.value) {
diskRotation += 0.042f * 16
if (diskRotation > 360f) diskRotation -= 360f
delay(16)
}
}
然后用 .graphicsLayer { rotationZ = diskRotation } 应用到盘的外层 Box。
唱针动画
用 animateFloatAsState 实现播放/暂停时平滑旋转:
Kotlin
val needleAngle by animateFloatAsState(
targetValue = if (isPlaying.value) 0f else -25f,
animationSpec = tween(500)
)
暂停时往外摆动,播放时归零压到唱片。
歌词解析 & 自动滚动
网易云的歌词是 .lrc 格式,里面的时间戳形如 [mm:ss.xx]。
解析
我写了一个简单的 loadLyricsFromRaw() 方法:
Kotlin
fun loadLyricsFromRaw(context: Context, @RawRes resId: Int): List<LyricLine> {
val raw = context.resources.openRawResource(resId).bufferedReader().readText()
return raw.lines().mapNotNull { parseLine(it) }.sortedBy { it.timestampMs }
}
每行用 substring 截掉时间和歌词文本,再转成毫秒。
自动滚动
歌词列表用 LazyColumn + rememberLazyListState()。
每次 currentLyricIndex 变化时,把目标行滚动到列表中间:
Kotlin
LaunchedEffect(currentIndex) {
val targetIndex = (currentIndex - offsetItems).coerceAtLeast(0)
listState.animateScrollToItem(targetIndex)
}
高亮当前行就是 Compose 最擅长的声明式刷新:
Kotlin
color = if (index == currentIndex) Color(0xFFD83B67) else Color.White.copy(alpha = 0.7f)
fontSize = if (index == currentIndex) 20.sp else 16.sp
点击切换唱片 / 歌词界面
这部分直接用一个 showLyrics 布尔状态控制:
Kotlin
Box(
Modifier.fillMaxSize().clickable { showLyrics = !showLyrics }
) {
if (!showLyrics) {
// 黑胶界面
} else {
LyricList(lyrics, currentLyricIndex)
}
}
点击区域覆盖整个中间部分,这样无论点唱片还是歌词都能切换。
播放进度 & 控制按钮
进度条用 Compose 的 Slider,value绑定播放进度比例。
拖动时同步歌词位置:
Kotlin
onValueChange = { newValue ->
val targetMs = (newValue * totalDuration).toLong()
currentLyricIndex = findCurrentLyricIndex(lyrics, targetMs)
}
按钮都是 IconButton,上一曲 / 下一曲 / 播放暂停直接改 songIndex 或调用 exoPlayer.pause()/play()。
完成后的效果
成品效果:
- 播放时黑胶转动,唱针压下
- 暂停时唱针抬起,唱片定格
- 点击中间切换到歌词页,歌词滚动到当前行并高亮
- 进度条拖动歌词跟着跳
- 背景封面模糊覆盖全屏,网易云同款沉浸感
整体源码
package com.example.test001
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import androidx.compose.ui.zIndex
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay
class MusicPlayerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MusicPlayerScreen()
}
}
}
// 新增: 歌词行类
data class LyricLine(val timestampMs: Long, val text: String)
// 新增: 解析 LRC 文件
fun loadLyricsFromRaw(context: Context, @androidx.annotation.RawRes resId: Int): List<LyricLine> {
val inputStream = context.resources.openRawResource(resId)
val raw = inputStream.bufferedReader().use { it.readText() }
val lines = raw.split("\n")
val parsed = mutableListOf<LyricLine>()
for (line in lines) {
val start = line.indexOf("[")
val end = line.indexOf("]")
if (start != -1 && end != -1) {
val timeStr = line.substring(start + 1, end) // mm:ss.xx
val lyricText = line.substring(end + 1).trim()
if (lyricText.isEmpty()) continue
val timeParts = timeStr.split(":")
val minute = timeParts[0].toInt()
val secondsDouble = timeParts[1].toDouble()
val second = secondsDouble.toInt()
val millisecond = ((secondsDouble - second) * 1000).toInt()
val timestampMs = minute * 60_000 + second * 1000 + millisecond
parsed.add(LyricLine(timestampMs.toLong(), lyricText))
}
}
parsed.sortBy { it.timestampMs }
return parsed
}
// 新增: 查找当前歌词行
fun findCurrentLyricIndex(lyrics: List<LyricLine>, positionMs: Long): Int {
for (i in lyrics.indices) {
if (positionMs >= lyrics[i].timestampMs &&
(i == lyrics.size - 1 || positionMs < lyrics[i + 1].timestampMs)
) {
return i
}
}
return 0
}
// 新增: 可组合歌词列表
@Composable
fun LyricList(lyrics: List<LyricLine>, currentIndex: Int) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val lineHeightPx = with(density) { 40.dp.toPx() }
// 当 currentIndex 改变时自动居中滚动
LaunchedEffect(currentIndex) {
if (lyrics.isNotEmpty()) {
val viewportHeightPx =
listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
val offsetItems = (viewportHeightPx / (lineHeightPx)).toInt() / 2 // 居中行数偏移
val targetIndex = (currentIndex - offsetItems).coerceAtLeast(0)
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(lyrics.size) { index ->
Text(
text = lyrics[index].text,
color = if (index == currentIndex) Color(0xFFD83B67) else Color.White.copy(alpha = 0.7f),
fontSize = if (index == currentIndex) 20.sp else 16.sp,
fontWeight = if (index == currentIndex) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier
.height(40.dp) // 固定高度,给计算居中用
.padding(vertical = 4.dp)
)
}
}
}
//========= UI页面 ==============
@SuppressLint("ConfigurationScreenWidthHeight")
@Composable
fun MusicPlayerScreen() {
val context = LocalContext.current
// 歌曲列表(只需填写raw与对应信息,多首均可)
data class Song(
val name: String,
val artist: String,
val coverRes: Int,
@androidx.annotation.RawRes val rawRes: Int,
@androidx.annotation.RawRes val lyricRes: Int // 新增: 对应歌词文件 raw 资源
)
val songList = listOf(
Song("最后一页", "江语晨", R.drawable.cover_demo, R.raw.music_demo1, R.raw.music1),
Song("跳楼机", "LBI利比", R.drawable.cover_demo2, R.raw.music_demo2, R.raw.music2),
Song("忘不掉的你", "h3R3", R.drawable.cover_demo3, R.raw.music_demo3, R.raw.music3),
Song("像晴天像雨天", "汪苏泷", R.drawable.cover_demo, R.raw.music_demo4, R.raw.music4),
// 如需添加更多,继续添加
)
var songIndex by remember { mutableIntStateOf(0) }
val songCount = songList.size
val curSong = songList[songIndex]
// 1.播放器
val exoPlayer = rememberExoPlayerMutable(context, curSong.rawRes)
val isPlaying = remember { mutableStateOf(false) }
val totalDuration = remember { mutableLongStateOf(0L) }
val currentProgress = remember { mutableFloatStateOf(0f) }
val currentTime = remember { mutableLongStateOf(0L) }
// 黑胶
val discRes = R.drawable.ic_disc
// 唱针
val needleRes = R.drawable.ic_needle3
// 黑胶背景
val discBackground = R.drawable.ic_disc_blackground
// ====== 新增: 歌词状态 ======
var lyrics by remember { mutableStateOf<List<LyricLine>>(emptyList()) }
var currentLyricIndex by remember { mutableStateOf(0) }
var showLyrics by remember { mutableStateOf(false) }
// 加载歌词(切换歌曲时执行)
LaunchedEffect(songIndex) {
lyrics = loadLyricsFromRaw(context, curSong.lyricRes)
}
//列表循环
DisposableEffect(exoPlayer, songIndex) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
// 列表循环
songIndex = if (songIndex < songCount - 1) songIndex + 1 else 0
}
}
}
exoPlayer.addListener(listener)
onDispose { exoPlayer.removeListener(listener) }
}
// 播放监听:进度等
LaunchedEffect(exoPlayer, isPlaying.value, songIndex) {
while (true) {
totalDuration.longValue = exoPlayer.duration.coerceAtLeast(1L)
currentTime.longValue = exoPlayer.currentPosition
currentProgress.floatValue = if (totalDuration.longValue > 0)
exoPlayer.currentPosition.toFloat() / totalDuration.longValue else 0f
// 新增: 歌词更新
currentLyricIndex = findCurrentLyricIndex(lyrics, exoPlayer.currentPosition)
delay(100)
}
}
// 自动播放、记得关闭单曲循环
LaunchedEffect(exoPlayer, songIndex) {
exoPlayer.repeatMode = Player.REPEAT_MODE_OFF
exoPlayer.playWhenReady = true
isPlaying.value = true
}
var diskRotation by remember { mutableFloatStateOf(0f) }
val rotationSpeed = 0.042f // 每帧递增度数,可调(速度)
// 动画:黑胶旋转,唱针旋转
LaunchedEffect(isPlaying.value) {
while (isPlaying.value) {
diskRotation += rotationSpeed * 16 // 16是大致每帧毫秒
if (diskRotation > 360f) diskRotation -= 360f
delay(16)
}
// 这里不处理归零,保持在当前角度,暂停状态
}
val needleAngle by animateFloatAsState(
targetValue = if (isPlaying.value) 0f else -25f, // 靠近时0,离开-25
animationSpec = tween(500)
)
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidth = configuration.screenWidthDp.dp
val screenHeight = configuration.screenHeightDp.dp
val reservedBottomSpace = 300.dp // 为底部控制区/信息区预留区域空间
// 1. 唱盘外框最大直径 = 比例(如68%)* 屏幕宽,不超过 屏幕高-底部栏
val maxDiscSize = screenWidth * 0.88f
val maxDiscHeight = screenHeight - reservedBottomSpace
val discSize = if (maxDiscSize < maxDiscHeight) maxDiscSize else maxDiscHeight
// 2. 细分
val backgroundSize = discSize * 0.98f // 背景直径=外框约98%
val blackDiscSize = discSize * 0.93f // 黑胶盘直径=外框约93%
val coverSize = discSize * 0.6f // 封面直径=黑胶盘约60%
val needleWidth = discSize * 0.32f // 唱针宽
val needleHeight = needleWidth * 1.8f // 唱针高
val needleOffsetX = discSize * 0.14f // 唱针X方向偏移
val needleOffsetY = -needleHeight * 0.55f // 唱针Y方向偏移
// ========= UI ============
Box(
Modifier
.fillMaxSize()
) {
// 背景封面高斯 + 遮罩
Image(
painter = painterResource(id = curSong.coverRes),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.blur(45.dp),
contentScale = ContentScale.Crop
)
Box(
Modifier
.fillMaxSize()
.background(Color(0x66000000))
)
// 顶部Bar
Row(
Modifier
.fillMaxWidth()
.padding(top = 40.dp, start = 16.dp, end = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = {
/*返回*/
(context as? Activity)?.finish()
(context as? Activity)?.overridePendingTransition(0, R.anim.slide_out_bottom)
}) {
Icon(Icons.Default.ArrowBackIosNew, null, tint = Color.White)
}
IconButton(onClick = {
/*分享*/
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_SUBJECT, "音乐分享")
intent.putExtra(
Intent.EXTRA_TEXT,
"我正在听好听的歌曲 “${curSong.name} - ${curSong.artist}”,推荐给你!"
)
context.startActivity(Intent.createChooser(intent, "分享音乐到..."))
}) {
Icon(Icons.Default.Share, null, tint = Color.White)
}
}
// ====== 中间: 唱盘or歌词 ======
Box(
Modifier
.fillMaxSize()
.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
showLyrics = !showLyrics
}
) {
if (!showLyrics) {
// =========== 优化黑胶盘与唱针合成区(自适应屏幕) ===========
Box(
Modifier
.align(Alignment.Center)
.size(discSize)
.offset(y = -discSize * 0.15f)
) {
// 黑胶盘合成体
Box(
Modifier
.align(Alignment.Center)
.size(backgroundSize)
.graphicsLayer { rotationZ = diskRotation }
) {
// 黑胶盘背景
Image(
painter = painterResource(id = discBackground),
contentDescription = null,
modifier = Modifier.matchParentSize()
)
// 黑胶盘
Box(
Modifier
.align(Alignment.Center)
.size(blackDiscSize)
) {
Image(
painter = painterResource(id = discRes),
contentDescription = null,
modifier = Modifier.matchParentSize()
)
}
// 封面
Box(
modifier = Modifier
.size(coverSize)
.align(Alignment.Center)
.clip(CircleShape)
.background(Color.White, CircleShape)
) {
Image(
painter = painterResource(id = curSong.coverRes),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
)
}
}
// 唱针
Image(
painter = painterResource(id = needleRes),
contentDescription = null,
modifier = Modifier
.size(width = needleWidth, height = needleHeight)
.align(Alignment.TopCenter)
.offset(x = needleOffsetX, y = needleOffsetY)
.graphicsLayer {
rotationZ = needleAngle
transformOrigin = TransformOrigin(0.12f, 0.13f)
}
.zIndex(2f)
)
}
} else {
// 歌词视图
Box(
Modifier
.padding(top = 65.dp)
.fillMaxWidth()
.fillMaxHeight(0.65f)
) {
LyricList(lyrics, currentLyricIndex)
}
}
}
var isLiked by remember { mutableStateOf(false) }
var likeCount by remember { mutableIntStateOf(520) }
var commentCount by remember { mutableIntStateOf(999) }
// 下部信息
Column(
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(32.dp))
Row(
Modifier
.fillMaxWidth(0.88f)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// 左边歌曲名+艺术家
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
curSong.name,
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
Text(
curSong.artist,
color = Color.White.copy(alpha = 0.78f),
fontSize = 15.sp,
maxLines = 1
)
}
// 右侧点赞/评论按钮+数量
Row(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
isLiked = !isLiked
likeCount += if (isLiked) 1 else -1
}
) {
Icon(
imageVector = if (isLiked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Like",
tint = if (isLiked) Color(0xFFD83B67) else Color.White
)
}
Text(
likeCount.toString(),
color = Color.White,
fontSize = 15.sp,
modifier = Modifier.padding(end = 8.dp)
)
IconButton(onClick = { /* 评论点击 */ }) {
Icon(
Icons.Default.Comment,
contentDescription = "Comment",
tint = Color.White
)
}
Text(
commentCount.toString(),
color = Color.White,
fontSize = 15.sp
)
}
}
Spacer(Modifier.height(18.dp))
// 播放进度
Row(
Modifier.fillMaxWidth(0.85f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
formatTime(currentTime.longValue),
color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp
)
Slider(
value = currentProgress.floatValue,
onValueChange = { newValue ->
currentProgress.floatValue = newValue
val targetMs =
(newValue * totalDuration.longValue).toLong().coerceAtLeast(0)
currentLyricIndex = findCurrentLyricIndex(lyrics, targetMs) // 新增: 拖动歌词跟跳
currentTime.longValue = targetMs
},
onValueChangeFinished = {
val targetMs =
(currentProgress.floatValue * totalDuration.longValue).toLong()
.coerceAtLeast(0)
exoPlayer.seekTo(targetMs)
},
modifier = Modifier.weight(1f),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.2f)
)
)
Text(
formatTime(totalDuration.longValue),
color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp
)
}
Spacer(Modifier.height(8.dp))
// 播放控制
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
// 上一曲
songIndex = if (songIndex > 0) songIndex - 1 else songCount - 1
}) {
Icon(
Icons.Default.SkipPrevious,
null,
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
// 播放/暂停
if (exoPlayer.isPlaying) exoPlayer.pause()
else exoPlayer.play()
isPlaying.value = exoPlayer.isPlaying
}) {
Icon(
if (isPlaying.value) Icons.Default.PauseCircle else Icons.Default.PlayCircle,
null, tint = Color.White, modifier = Modifier.size(66.dp)
)
}
Spacer(Modifier.width(8.dp))
IconButton(onClick = {
// 下一曲
songIndex = if (songIndex < songCount - 1) songIndex + 1 else 0
}) {
Icon(
Icons.Default.SkipNext,
null,
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
}
Spacer(Modifier.height(42.dp))
}
}
}
// ============= 工具代码 ================
// 快速格式化时间
private fun formatTime(ms: Long): String {
val totalSec = (ms / 1000).toInt()
val min = totalSec / 60
val sec = totalSec % 60
return "%02d:%02d".format(min, sec)
}
// ExoPlayer记住每一首资源
@Composable
fun rememberExoPlayerMutable(context: Context, @androidx.annotation.RawRes rawRes: Int): ExoPlayer {
val exoPlayer = remember(rawRes) {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri("rawresource://${context.packageName}/$rawRes")
setMediaItem(mediaItem)
prepare()
}
}
DisposableEffect(exoPlayer) {
//可自动释放的ExoPlayer
onDispose {
exoPlayer.release()
}
}
return exoPlayer
}
一些感受
用 Compose 做这种复杂 UI 页面,状态管理和动画控制比 View 时代直观很多,比如歌词高亮和滚动基本是状态驱动自动更新。不用手动刷新 UI,代码量也比我预想的少。
唯一要注意的是动画和 LaunchedEffect 要合理关停,否则旋转和播放器监听可能会占用资源。
如果你也想练练 Compose 动画 + 多媒体播放,仿网易云这种黑胶 + 歌词滚动的界面会很有成就感,而且手机一跑起来就很“网易云味”。