用 Jetpack Compose 实现仿网易云音乐播放页 + 歌词滚动
最近在做一个 Compose 小项目时,手痒临时加了一个“仿网易云播放页”的功能,主要包含两个核心效果:
- 黑胶唱片旋转 + 唱针动画
- 点击切换歌词页 + 歌词自动滚动高亮
这篇文章就记录一下完整实现思路,以及实现过程中遇到的一些细节坑。
Demo效果图
技术栈选型
老实说这类界面用传统的 View 布局也能实现,但这次我想用 Compose 的声明式 UI 来试试。
搭配:
- Jetpack Compose 渲染 UI
- ExoPlayer 播放音频
- Raw 资源文件存放音乐和
.lrc
歌词 LaunchedEffect
+remember
管理动画和播放状态
页面结构大致规划
跟网易云差不多,分成几块:
- 模糊背景:用封面图铺底,
Modifier.blur()
+ 半透明遮罩 - 顶部按钮:返回、分享
- 中间区域:
- 默认显示唱片 + 唱针
- 点击切换到歌词列表
- 底部操作区:歌曲信息、点赞评论、进度条、播放控制
这样用一个 Box
根布局,把背景、前景分层加进去。
播放器 & 歌曲数据
播放用的是 ExoPlayer,我们把每首歌的封面、音频 raw 资源、歌词 raw 资源提前写进一个 Song
数据类,然后用 rememberExoPlayerMutable
保持每次切歌都能创建新的播放器并释放旧的:
val exoPlayer = rememberExoPlayerMutable(context, curSong.rawRes) val isPlaying = remember { mutableStateOf(false) }
注意:MediaItem.fromUri("rawresource://...")
这种 URI 格式可以直接播放 raw 里的文件。
黑胶唱片旋转 & 唱针摆动
这部分是最有趣的。
旋转黑胶
Compose 没有“无限旋转”的现成组件,但可以自己控制一个 rotationZ
值,每帧递增:
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
实现播放/暂停时平滑旋转:
val needleAngle by animateFloatAsState( targetValue = if (isPlaying.value) 0f else -25f, animationSpec = tween(500) )
暂停时往外摆动,播放时归零压到唱片。
歌词解析 & 自动滚动
网易云的歌词是 .lrc
格式,里面的时间戳形如 [mm:ss.xx]
。
解析
我写了一个简单的 loadLyricsFromRaw()
方法:
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
变化时,把目标行滚动到列表中间:
LaunchedEffect(currentIndex) { val targetIndex = (currentIndex - offsetItems).coerceAtLeast(0) listState.animateScrollToItem(targetIndex) }
高亮当前行就是 Compose 最擅长的声明式刷新:
color = if (index == currentIndex) Color(0xFFD83B67) else Color.White.copy(alpha = 0.7f) fontSize = if (index == currentIndex) 20.sp else 16.sp
点击切换唱片 / 歌词界面
这部分直接用一个 showLyrics
布尔状态控制:
Box( Modifier.fillMaxSize().clickable { showLyrics = !showLyrics } ) { if (!showLyrics) { // 黑胶界面 } else { LyricList(lyrics, currentLyricIndex) } }
点击区域覆盖整个中间部分,这样无论点唱片还是歌词都能切换。
播放进度 & 控制按钮
进度条用 Compose 的 Slider
,value
绑定播放进度比例。
拖动时同步歌词位置:
onValueChange = { newValue -> val targetMs = (newValue * totalDuration).toLong() currentLyricIndex = findCurrentLyricIndex(lyrics, targetMs) }
按钮都是 IconButton
,上一曲 / 下一曲 / 播放暂停直接改 songIndex
或调用 exoPlayer.pause()/play()
。
完成后的效果
成品效果:
- 播放时黑胶转动,唱针压下
- 暂停时唱针抬起,唱片定格
- 点击中间切换到歌词页,歌词滚动到当前行并高亮
- 进度条拖动歌词跟着跳
- 背景封面模糊覆盖全屏,网易云同款沉浸感
整体源码
package com.example.test001import 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.ExoPlayerimport kotlinx.coroutines.delayclass 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.xxval lyricText = line.substring(end + 1).trim()if (lyricText.isEmpty()) continueval 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 + millisecondparsed.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.currentval lineHeightPx = with(density) { 40.dp.toPx() }// 当 currentIndex 改变时自动居中滚动LaunchedEffect(currentIndex) {if (lyrics.isNotEmpty()) {val viewportHeightPx =listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffsetval 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.sizeval 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.currentPositioncurrentProgress.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_OFFexoPlayer.playWhenReady = trueisPlaying.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 -= 360fdelay(16)}// 这里不处理归零,保持在当前角度,暂停状态}val needleAngle by animateFloatAsState(targetValue = if (isPlaying.value) 0f else -25f, // 靠近时0,离开-25animationSpec = tween(500))val configuration = LocalConfiguration.currentval density = LocalDensity.currentval screenWidth = configuration.screenWidthDp.dpval screenHeight = configuration.screenHeightDp.dpval reservedBottomSpace = 300.dp // 为底部控制区/信息区预留区域空间// 1. 唱盘外框最大直径 = 比例(如68%)* 屏幕宽,不超过 屏幕高-底部栏val maxDiscSize = screenWidth * 0.88fval maxDiscHeight = screenHeight - reservedBottomSpaceval 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)))// 顶部BarRow(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 = needleAngletransformOrigin = 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 = !isLikedlikeCount += 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 = newValueval 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 / 60val sec = totalSec % 60return "%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) {//可自动释放的ExoPlayeronDispose {exoPlayer.release()}}return exoPlayer
}
一些感受
用 Compose 做这种复杂 UI 页面,状态管理和动画控制比 View 时代直观很多,比如歌词高亮和滚动基本是状态驱动自动更新。不用手动刷新 UI,代码量也比我预想的少。
唯一要注意的是动画和 LaunchedEffect
要合理关停,否则旋转和播放器监听可能会占用资源。
如果你也想练练 Compose 动画 + 多媒体播放,仿网易云这种黑胶 + 歌词滚动的界面会很有成就感,而且手机一跑起来就很“网易云味”。