当前位置: 首页 > news >正文

用 Jetpack Compose 实现仿网易云音乐播放页 + 歌词滚动

最近在做一个 Compose 小项目时,手痒临时加了一个“仿网易云播放页”的功能,主要包含两个核心效果:

  • 黑胶唱片旋转 + 唱针动画
  • 点击切换歌词页 + 歌词自动滚动高亮

这篇文章就记录一下完整实现思路,以及实现过程中遇到的一些细节坑。


Demo效果图

技术栈选型

老实说这类界面用传统的 View 布局也能实现,但这次我想用 Compose 的声明式 UI 来试试。
搭配:

  • Jetpack Compose 渲染 UI
  • ExoPlayer 播放音频
  • Raw 资源文件存放音乐和 .lrc 歌词
  • LaunchedEffect + remember 管理动画和播放状态

页面结构大致规划

跟网易云差不多,分成几块:

  1. 模糊背景:用封面图铺底,Modifier.blur() + 半透明遮罩
  2. 顶部按钮:返回、分享
  3. 中间区域
    • 默认显示唱片 + 唱针
    • 点击切换到歌词列表
  4. 底部操作区:歌曲信息、点赞评论、进度条、播放控制

这样用一个 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 的 Slidervalue绑定播放进度比例。

拖动时同步歌词位置:

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 动画 + 多媒体播放,仿网易云这种黑胶 + 歌词滚动的界面会很有成就感,而且手机一跑起来就很“网易云味”。

http://www.dtcms.com/a/470238.html

相关文章:

  • 既然根据时间可推算太阳矢量,为何还需要太阳敏感器?
  • 做娱乐新闻的网站有哪些网站建设教材
  • ORACLE数据库字符集
  • 本机做网站服务上传到凡科手机网站建设开发
  • 谷歌和IBM:量子计算新时代即将到来
  • 做那种事免费网站WordPress网站动漫你在
  • ROS 点云配准与姿态估计
  • 活动预告|海天瑞声与您相约 NCMMSC 2025
  • Java入门级教程22——Socket编程
  • 【Linux系统编程】2. Linux基本指令(上)
  • 网站系统介绍如何设置wordpress的内存
  • 毕业设计做网站做不出网站建设手机端pc端分开
  • Git删除本地与远程tag操作指南
  • 爱网站推广优化wordpress第三方登录教程
  • 23种设计模式——享元模式(Flyweight Pattern)
  • 游戏编程模式-享元模式(Flyweight)
  • 新郑做网站优化桂林网站优化公司
  • B站排名优化:知识、娱乐、生活类内容的差异化实操策略
  • 闵行网站制作设计公司昆明哪些做网站建设的公司
  • Spring Boot 3.x核心特性与性能优化实战
  • 域名解析后多久打开网站建个人网站
  • 基于MATLAB的PIV(粒子图像测速) 实现方案
  • 北京市网站建设企业怎么自己开发一个app软件
  • 基于springboot的技术交流和分享平台的设计与实现
  • Spring Boot 处理JSON的方法
  • 在Gin项目中使用API接口文档Swagger
  • asp.net 4.0网站开发高级视频教程订阅号怎么做免费的视频网站吗
  • 重庆响应式网站制作没有后台的网站怎么做排名
  • ENSP Pro Lab笔记:配置STP/RSTP/MSTP(1)
  • ajax 效果网站中国室内装饰设计网