案例6:音视频播放器完整实现¶
📋 案例概述¶
项目简介¶
构建一个功能完善的音视频播放器应用,支持本地和网络媒体播放、播放列表管理、后台播放、均衡器等功能。本案例展示ExoPlayer/Media3的高级用法和音频开发最佳实践。
预计开发时间: 8-10小时 难度等级: ⭐⭐⭐⭐⭐ 涉及知识点: ExoPlayer、MediaSession、后台播放、音频焦点、通知栏控制
功能特性¶
- 🎵 音频/视频播放
- 📁 本地媒体库扫描
- 🌐 网络流媒体播放
- 📋 播放列表管理
- 🔄 后台播放支持
- 🎛️ 均衡器与音效
- ⏱️ 睡眠定时器
- 🎨 多种播放模式(顺序、随机、单曲循环)
🏗️ 项目架构¶
Text Only
app/
├── data/
│ ├── local/
│ │ ├── MediaDatabase.kt
│ │ ├── SongDao.kt
│ │ ├── PlaylistDao.kt
│ │ └── entity/
│ │ ├── SongEntity.kt
│ │ └── PlaylistEntity.kt
│ ├── repository/
│ │ ├── MediaRepository.kt
│ │ └── PlaylistRepository.kt
│ └── model/
│ ├── Song.kt
│ ├── Playlist.kt
│ └── PlaybackState.kt
├── service/
│ ├── MusicPlaybackService.kt
│ ├── MediaSessionCallback.kt
│ └── NotificationHelper.kt
├── player/
│ ├── ExoPlayerManager.kt
│ ├── AudioFocusHelper.kt
│ └── EqualizerManager.kt
├── ui/
│ ├── player/
│ │ ├── PlayerScreen.kt
│ │ ├── PlayerViewModel.kt
│ │ └── components/
│ │ ├── AlbumArt.kt
│ │ ├── PlaybackControls.kt
│ │ ├── ProgressSlider.kt
│ │ └── PlaylistSheet.kt
│ ├── library/
│ │ ├── LibraryScreen.kt
│ │ ├── SongsScreen.kt
│ │ ├── AlbumsScreen.kt
│ │ └── ArtistsScreen.kt
│ ├── playlist/
│ │ ├── PlaylistScreen.kt
│ │ └── PlaylistDetailScreen.kt
│ └── components/
│ ├── SongItem.kt
│ ├── MiniPlayer.kt
│ └── Visualizer.kt
└── utils/
├── MediaScanner.kt
├── TimeUtils.kt
└── PermissionsHelper.kt
🛠️ 技术实现¶
1. 数据模型¶
Song.kt¶
Kotlin
// 歌曲数据模型,封装媒体库中每首歌的元信息
data class Song(
val id: Long,
val title: String,
val artist: String,
val album: String,
val albumId: Long,
val duration: Long, // 时长(毫秒)
val path: String,
val trackNumber: Int = 0,
val year: Int = 0,
val dateAdded: Long = 0,
val isFavorite: Boolean = false
) {
// 通过 albumId 拼接专辑封面的 Content URI
val albumArtUri: Uri
get() = ContentUris.withAppendedId(
Uri.parse("content://media/external/audio/albumart"),
albumId
)
// 通过 id 拼接音频文件的 Content URI,供 ExoPlayer 播放使用
val contentUri: Uri
get() = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
}
// 播放列表数据模型
data class Playlist(
val id: Long,
val name: String,
val songCount: Int = 0,
val createdAt: Long = System.currentTimeMillis()
)
// 播放状态数据类,UI 层通过观察此对象来刷新界面
data class PlaybackState(
val isPlaying: Boolean = false,
val currentSong: Song? = null,
val currentPosition: Long = 0, // 当前播放进度(毫秒)
val duration: Long = 0, // 总时长(毫秒)
val playbackMode: PlaybackMode = PlaybackMode.ORDER,
val isBuffering: Boolean = false
)
enum class PlaybackMode {
ORDER, // 顺序播放
SHUFFLE, // 随机播放
REPEAT_ONE, // 单曲循环
REPEAT_ALL // 列表循环
}
2. ExoPlayer管理器¶
Kotlin
@Singleton // Hilt 单例,全局共享同一个播放器管理实例
class ExoPlayerManager @Inject constructor(
@ApplicationContext private val context: Context,
private val audioFocusHelper: AudioFocusHelper
) {
private var player: ExoPlayer? = null
// 使用 StateFlow 实现响应式播放状态,UI 层可直接 collect
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState = _playbackState.asStateFlow()
// 当前播放队列
private val _currentPlaylist = MutableStateFlow<List<Song>>(emptyList())
val currentPlaylist = _currentPlaylist.asStateFlow()
private var currentIndex = 0
// ExoPlayer 事件监听器,将播放器内部状态同步到 StateFlow
private val playerListener = object : Player.Listener {
// 播放状态变化:缓冲中、就绪、结束等
override fun onPlaybackStateChanged(state: Int) {
_playbackState.update {
it.copy(isBuffering = state == Player.STATE_BUFFERING)
}
}
// 播放/暂停状态变化
override fun onIsPlayingChanged(isPlaying: Boolean) {
_playbackState.update { it.copy(isPlaying = isPlaying) }
}
// 切歌时触发(自动切歌或手动切歌)
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateCurrentSong()
}
// 播放位置跳变时触发(如 seek 操作)
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
updatePosition()
}
}
// 懒初始化 ExoPlayer 实例(单例模式,避免重复创建)
fun initializePlayer(): ExoPlayer {
if (player == null) {
player = ExoPlayer.Builder(context)
// 设置音频属性,第二个参数 true 表示自动处理音频焦点
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build(),
true // handleAudioFocus: 自动管理音频焦点
)
// 防止 CPU 休眠导致播放中断
.setWakeMode(C.WAKE_MODE_LOCAL)
.build()
.apply {
addListener(playerListener)
}
}
return player!!
}
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
if (songs.isEmpty()) return
_currentPlaylist.value = songs
currentIndex = startIndex
// 将 Song 列表转为 ExoPlayer 的 MediaItem 列表
val mediaItems = songs.map { song ->
MediaItem.Builder()
.setUri(song.contentUri) // 音频文件 URI
.setMediaId(song.id.toString()) // 唯一标识,用于跟踪当前播放项
.setMediaMetadata(
// 设置元数据,供通知栏和 MediaSession 显示
MediaMetadata.Builder()
.setTitle(song.title)
.setArtist(song.artist)
.setAlbumTitle(song.album)
.build()
)
.build()
}
player?.apply {
setMediaItems(mediaItems, startIndex, 0) // 设置播放队列,从 startIndex 开始
prepare() // 准备资源(异步加载)
play() // 开始播放
}
updateCurrentSong()
}
// 切换播放/暂停状态
fun playPause() {
player?.let {
if (it.isPlaying) {
it.pause()
} else {
// 播放前先请求音频焦点,获取成功才播放
if (audioFocusHelper.requestAudioFocus()) {
it.play()
}
}
}
}
fun skipToNext() {
player?.seekToNext()
}
fun skipToPrevious() {
player?.seekToPrevious()
}
fun seekTo(position: Long) {
player?.seekTo(position)
}
// 设置播放模式:将自定义枚举映射为 ExoPlayer 的 shuffle/repeat 配置
fun setPlaybackMode(mode: PlaybackMode) {
player?.apply {
shuffleModeEnabled = mode == PlaybackMode.SHUFFLE
repeatMode = when (mode) {
PlaybackMode.REPEAT_ONE -> Player.REPEAT_MODE_ONE // 单曲循环
PlaybackMode.REPEAT_ALL -> Player.REPEAT_MODE_ALL // 列表循环
else -> Player.REPEAT_MODE_OFF
}
}
_playbackState.update { it.copy(playbackMode = mode) }
}
fun setVolume(volume: Float) {
player?.volume = volume
}
// 释放播放器资源,防止内存泄漏
fun release() {
player?.removeListener(playerListener) // 先移除监听器
player?.release() // 释放底层解码资源
player = null
audioFocusHelper.abandonAudioFocus() // 释放音频焦点
}
private fun updateCurrentSong() {
val song = _currentPlaylist.value.getOrNull(player?.currentMediaItemIndex ?: 0)
_playbackState.update {
it.copy(
currentSong = song,
duration = player?.duration ?: 0
)
}
}
private fun updatePosition() {
_playbackState.update {
it.copy(currentPosition = player?.currentPosition ?: 0)
}
}
fun startPositionUpdates() {
// 使用协程定期更新播放位置
}
}
3. 音频焦点管理¶
Kotlin
// 音频焦点管理器:处理与其他音频应用(如电话、导航)的焦点竞争
class AudioFocusHelper @Inject constructor(
@ApplicationContext private val context: Context
) {
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var onAudioFocusChange: ((Boolean) -> Unit)? = null
// 音频焦点变化回调
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
onAudioFocusChange?.invoke(true) // 重新获得焦点,可恢复播放
}
AudioManager.AUDIOFOCUS_LOSS, // 永久丢失焦点(如另一个播放器开始播放)
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { // 短暂丢失焦点(如来电)
onAudioFocusChange?.invoke(false)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// 可以降低音量继续播放(如导航语音提示)
}
}
}
// 请求音频焦点,返回是否成功获取
fun requestAudioFocus(): Boolean {
// Android 8.0+ 使用新版 AudioFocusRequest API
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
audioManager.requestAudioFocus(focusRequest)
} else {
// 兼容旧版本 API(已废弃但仍需支持低版本设备)
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// 使用AudioFocusRequest
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusChangeListener)
}
}
fun setOnAudioFocusChangeListener(listener: (Boolean) -> Unit) {
onAudioFocusChange = listener
}
}
4. 播放服务¶
Kotlin
// 媒体播放前台服务:继承 MediaLibraryService 实现后台播放与系统媒体集成
class MusicPlaybackService : MediaLibraryService() {
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
@Inject
lateinit var exoPlayerManager: ExoPlayerManager
@Inject
lateinit var notificationHelper: NotificationHelper
override fun onCreate() {
super.onCreate()
player = exoPlayerManager.initializePlayer()
// 创建 MediaLibrarySession,连接播放器与系统媒体控制
// 外部控制端(通知栏、蓝牙、Android Auto)通过此会话操控播放
mediaLibrarySession = MediaLibrarySession.Builder(
this,
player,
object : MediaLibrarySession.Callback {
// 当外部控制端请求添加媒体项时的回调
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
// 处理添加媒体项
return Futures.immediateFuture(mediaItems)
}
}
).build()
// 设置自定义通知栏样式
setMediaNotificationProvider(notificationHelper)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
// 用户划掉应用时调用:若已暂停或无内容则停止服务
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
// 服务销毁时释放所有资源
override fun onDestroy() {
mediaLibrarySession.release() // 释放媒体会话
exoPlayerManager.release() // 释放播放器
super.onDestroy()
}
}
5. 通知栏控制¶
Kotlin
// 自定义通知栏控制:继承 DefaultMediaNotificationProvider 定制播放通知
class NotificationHelper @Inject constructor(
@ApplicationContext private val context: Context
) : DefaultMediaNotificationProvider(context) {
// 通知栏小图标
override fun getSmallIcon(session: MediaSession): Int {
return R.drawable.ic_notification
}
// 点击通知栏时的跳转意图:打开播放界面
override fun getNotificationContentIntent(session: MediaSession): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
// SINGLE_TOP 避免重复创建 Activity
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
action = ACTION_OPEN_PLAYER
}
return PendingIntent.getActivity(
context,
0,
intent,
// IMMUTABLE: Android 12+ 要求; UPDATE_CURRENT: 更新已有 PendingIntent
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
companion object {
const val ACTION_OPEN_PLAYER = "action_open_player"
const val NOTIFICATION_CHANNEL_ID = "music_playback_channel"
const val NOTIFICATION_ID = 1
}
}
6. UI层实现¶
PlayerScreen.kt¶
Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerScreen(
viewModel: PlayerViewModel = hiltViewModel(),
onBackClick: () -> Unit
) {
val playbackState by viewModel.playbackState.collectAsState()
val playlist by viewModel.currentPlaylist.collectAsState()
var showPlaylist by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("正在播放") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
actions = {
IconButton(onClick = { showPlaylist = true }) {
Icon(Icons.Default.QueueMusic, contentDescription = "播放列表")
}
IconButton(onClick = { /* 更多选项 */ }) {
Icon(Icons.Default.MoreVert, contentDescription = "更多")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(32.dp))
// 专辑封面
AlbumArt(
song = playbackState.currentSong,
isPlaying = playbackState.isPlaying,
modifier = Modifier
.size(280.dp)
.clip(RoundedCornerShape(16.dp))
)
Spacer(modifier = Modifier.height(32.dp))
// 歌曲信息
playbackState.currentSong?.let { song ->
Text(
text = song.title,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = song.artist,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(32.dp))
// 进度条
ProgressSlider(
position = playbackState.currentPosition,
duration = playbackState.duration,
onSeek = { viewModel.seekTo(it) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
// 播放控制
PlaybackControls(
isPlaying = playbackState.isPlaying,
playbackMode = playbackState.playbackMode,
onPlayPause = { viewModel.playPause() },
onNext = { viewModel.skipToNext() },
onPrevious = { viewModel.skipToPrevious() },
onModeChange = { viewModel.setPlaybackMode(it) }
)
Spacer(modifier = Modifier.height(24.dp))
// 额外控制
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
IconButton(onClick = { /* 收藏 */ }) {
Icon(Icons.Outlined.FavoriteBorder, contentDescription = "收藏")
}
IconButton(onClick = { /* 均衡器 */ }) {
Icon(Icons.Default.Equalizer, contentDescription = "均衡器")
}
IconButton(onClick = { /* 定时 */ }) {
Icon(Icons.Default.Timer, contentDescription = "定时")
}
IconButton(onClick = { /* 分享 */ }) {
Icon(Icons.Default.Share, contentDescription = "分享")
}
}
}
}
if (showPlaylist) {
PlaylistSheet(
playlist = playlist,
currentSong = playbackState.currentSong,
onDismiss = { showPlaylist = false },
onSongClick = { viewModel.playSong(it) }
)
}
}
PlaybackControls.kt¶
Kotlin
@Composable
fun PlaybackControls(
isPlaying: Boolean,
playbackMode: PlaybackMode,
onPlayPause: () -> Unit,
onNext: () -> Unit,
onPrevious: () -> Unit,
onModeChange: (PlaybackMode) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// 播放模式
IconButton(onClick = {
val nextMode = when (playbackMode) {
PlaybackMode.ORDER -> PlaybackMode.SHUFFLE
PlaybackMode.SHUFFLE -> PlaybackMode.REPEAT_ALL
PlaybackMode.REPEAT_ALL -> PlaybackMode.REPEAT_ONE
PlaybackMode.REPEAT_ONE -> PlaybackMode.ORDER
}
onModeChange(nextMode)
}) {
Icon(
imageVector = when (playbackMode) {
PlaybackMode.ORDER -> Icons.Default.Repeat
PlaybackMode.SHUFFLE -> Icons.Default.Shuffle
PlaybackMode.REPEAT_ALL -> Icons.Default.Repeat
PlaybackMode.REPEAT_ONE -> Icons.Default.RepeatOne
},
contentDescription = "播放模式",
tint = if (playbackMode != PlaybackMode.ORDER) {
MaterialTheme.colorScheme.primary
} else {
LocalContentColor.current
}
)
}
// 上一首
IconButton(onClick = onPrevious) {
Icon(
imageVector = Icons.Default.SkipPrevious,
contentDescription = "上一首",
modifier = Modifier.size(40.dp)
)
}
// 播放/暂停
FilledIconButton(
onClick = onPlayPause,
modifier = Modifier.size(72.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "暂停" else "播放",
modifier = Modifier.size(40.dp)
)
}
// 下一首
IconButton(onClick = onNext) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "下一首",
modifier = Modifier.size(40.dp)
)
}
// 播放列表
IconButton(onClick = { /* 播放列表 */ }) {
Icon(Icons.Default.QueueMusic, contentDescription = "播放列表")
}
}
}
ProgressSlider.kt¶
Kotlin
@Composable
fun ProgressSlider(
position: Long,
duration: Long,
onSeek: (Long) -> Unit,
modifier: Modifier = Modifier
) {
var sliderPosition by remember { mutableFloatStateOf(0f) } // 滑块位置 0.0~1.0
var isDragging by remember { mutableStateOf(false) } // 是否正在拖动
// 只在不拖动时根据实际播放进度更新滑块,避免拖动时跳动
LaunchedEffect(position, duration) {
if (!isDragging && duration > 0) {
sliderPosition = position.toFloat() / duration.toFloat()
}
}
Column(modifier = modifier) {
Slider(
value = sliderPosition,
onValueChange = {
sliderPosition = it
isDragging = true // 标记拖动状态,阻止外部更新
},
onValueChangeFinished = {
// 拖动结束时执行 seek 跳转
onSeek((sliderPosition * duration).toLong())
isDragging = false
},
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatDuration(if (isDragging) (sliderPosition * duration).toLong() else position),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatDuration(duration),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 将毫秒时长格式化为 "分:秒" 或 "时:分:秒"
fun formatDuration(durationMs: Long): String {
val seconds = (durationMs / 1000) % 60
val minutes = (durationMs / (1000 * 60)) % 60
val hours = durationMs / (1000 * 60 * 60)
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%d:%02d", minutes, seconds)
}
}
7. 媒体扫描器¶
Kotlin
// 本地媒体扫描器:通过 ContentResolver 查询系统媒体库
class MediaScanner @Inject constructor(
@ApplicationContext private val context: Context
) {
// 返回 Flow 实现异步流式加载,在 IO 线程执行
fun scanLocalMusic(): Flow<List<Song>> = flow {
val songs = mutableListOf<Song>()
// 定义查询需要的列(投影)
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.TRACK,
MediaStore.Audio.Media.YEAR,
MediaStore.Audio.Media.DATE_ADDED
)
val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0" // 仅查询音乐文件
val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC" // 按标题排序
// 通过 ContentResolver 查询外部存储的音频文件
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, // 外部存储音频 URI
projection,
selection,
null,
sortOrder
)?.use { cursor -> // use 自动关闭 Cursor 防止内存泄漏
// 预获取各列索引,避免每行重复查找
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
val trackColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
val yearColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.YEAR)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
// 遍历 Cursor 逐行读取歌曲信息
while (cursor.moveToNext()) {
songs.add(
Song(
id = cursor.getLong(idColumn),
title = cursor.getString(titleColumn) ?: "Unknown",
artist = cursor.getString(artistColumn) ?: "Unknown Artist",
album = cursor.getString(albumColumn) ?: "Unknown Album",
albumId = cursor.getLong(albumIdColumn),
duration = cursor.getLong(durationColumn),
path = cursor.getString(pathColumn) ?: "",
trackNumber = cursor.getInt(trackColumn),
year = cursor.getInt(yearColumn),
dateAdded = cursor.getLong(dateAddedColumn)
)
)
}
}
emit(songs) // 将结果发送到 Flow 下游
}.flowOn(Dispatchers.IO) // 切换到 IO 线程执行查询
}
8. ViewModel实现¶
Kotlin
@HiltViewModel // Hilt 注入 ViewModel,可在 Compose 中通过 hiltViewModel() 获取
class PlayerViewModel @Inject constructor(
private val exoPlayerManager: ExoPlayerManager,
private val mediaRepository: MediaRepository
) : ViewModel() {
// 直接暴露播放器管理器的 StateFlow 给 UI 层
val playbackState = exoPlayerManager.playbackState
val currentPlaylist = exoPlayerManager.currentPlaylist
init {
// 每秒轮询更新播放进度(用于进度条实时刷新)
viewModelScope.launch {
while (true) {
delay(1000)
// 更新播放位置
}
}
}
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
exoPlayerManager.playSongs(songs, startIndex)
}
// 播放指定歌曲:优先在当前队列中定位,找不到则作为单曲播放
fun playSong(song: Song) {
val currentList = currentPlaylist.value
val index = currentList.indexOfFirst { it.id == song.id }
if (index >= 0) {
exoPlayerManager.playSongs(currentList, index) // 在现有队列中跳转
} else {
exoPlayerManager.playSongs(listOf(song), 0) // 创建新的单曲队列
}
}
fun playPause() {
exoPlayerManager.playPause()
}
fun skipToNext() {
exoPlayerManager.skipToNext()
}
fun skipToPrevious() {
exoPlayerManager.skipToPrevious()
}
fun seekTo(position: Long) {
exoPlayerManager.seekTo(position)
}
fun setPlaybackMode(mode: PlaybackMode) {
exoPlayerManager.setPlaybackMode(mode)
}
// 添加到播放列表(在协程中执行数据库操作)
fun addToPlaylist(song: Song) {
viewModelScope.launch {
mediaRepository.addToPlaylist(song)
}
}
}
🔧 依赖配置¶
Kotlin
dependencies {
// Media3 / ExoPlayer —— 现代媒体播放核心库
implementation("androidx.media3:media3-exoplayer:1.2.0") // 播放器核心
implementation("androidx.media3:media3-session:1.2.0") // MediaSession 支持(通知栏、蓝牙控制)
implementation("androidx.media3:media3-ui:1.2.0") // 播放器 UI 组件
implementation("androidx.media3:media3-exoplayer-dash:1.2.0") // DASH 自适应流支持
implementation("androidx.media3:media3-exoplayer-hls:1.2.0") // HLS 直播流支持
// Room —— 本地数据库(存储播放列表、收藏等)
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1") // 协程扩展
kapt("androidx.room:room-compiler:2.6.1") // 注解处理器
// Coil —— 图片加载(用于专辑封面)
implementation("io.coil-kt:coil-compose:2.5.0")
// Hilt —— 依赖注入框架
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") // Compose 导航集成
// Accompanist Permissions —— 简化运行时权限请求
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
}
🎯 练习任务¶
基础任务¶
- ✅ 完成音频播放功能
- ✅ 实现播放列表管理
- ✅ 添加后台播放支持
进阶任务¶
- 🔄 实现均衡器功能
- 🔄 添加歌词显示
- 🔄 实现音频可视化效果
挑战任务¶
- 🎯 实现视频播放功能
- 🎯 添加在线音乐搜索
- 🎯 实现音效调节(混响、低音增强等)
📚 学习要点¶
- ExoPlayer: 现代Android媒体播放解决方案
- MediaSession: 媒体会话管理与系统集成
- 后台播放: 前台服务与通知栏控制
- 音频焦点: 处理音频焦点变化
- 媒体库: 扫描和管理本地媒体文件