跳转至

案例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")
}

🎯 练习任务

基础任务

  1. ✅ 完成音频播放功能
  2. ✅ 实现播放列表管理
  3. ✅ 添加后台播放支持

进阶任务

  1. 🔄 实现均衡器功能
  2. 🔄 添加歌词显示
  3. 🔄 实现音频可视化效果

挑战任务

  1. 🎯 实现视频播放功能
  2. 🎯 添加在线音乐搜索
  3. 🎯 实现音效调节(混响、低音增强等)

📚 学习要点

  1. ExoPlayer: 现代Android媒体播放解决方案
  2. MediaSession: 媒体会话管理与系统集成
  3. 后台播放: 前台服务与通知栏控制
  4. 音频焦点: 处理音频焦点变化
  5. 媒体库: 扫描和管理本地媒体文件

🔗 相关章节