原文链接
前言
这里使用KMP
简单实现一个跨平台音乐播放器的功能,包含音乐播放、暂停、拖动,上/下一首,设置播放模式(随机,循环,单曲)等基础播放器实现。其他基础功能可以参考Kotlin Compose Multiplatform下导航解决方案、构建跨平台的客户端界面、Kotlin Compose Multiplatform下实现HTTP请求等文章
实现
公共模块
由于目前没有统一的播放实现方案,我们这里还是需要使用actual
+expect
写入各个平台的实现,首先我们一个保持播放器状态的数据结构,如下:
@Stable
class MusicPlayerState {
var isPlaying by mutableStateOf(false)
internal set
var isBuffering by mutableStateOf(false)
var currentTime by mutableStateOf(0.0)
var totalDuration by mutableStateOf(0.0)
var currentPlayId by mutableStateOf("")
var playModel by mutableStateOf(MusicPlayModel.ORDER.ordinal)
fun toBack() {
isPlaying = false
isBuffering = false
currentTime = 0.0
totalDuration = 0.0
}
}
这个数据结构是沟通用户界面和实现逻辑的桥梁,通过这些字段让界面和逻辑可以相互感知。比如当播放停滞时,播放器将isPlaying
置为false
,用户界面图标改变。当用户在用户界面切换播放模式时,playModel
改变,播放器触发下一首时使用新的播放模式计算下一首
然后是需要调用播放器的抽象接口,按照前言所描述,我们需要基本实现的播放器功能如下:
expect class AudioPlayer(musicPlayerState: MusicPlayerState) {
fun start(id: String)
fun play()
fun pause()
fun next()
fun prev()
fun seekTo(time: Double)
fun cleanUp()
fun clearSongs()
fun addSongList(songs: Map<String, AudioSimpleModel>)
}
从后台获取的音频数据结构如下:
@Serializable
data class AudioSimpleModel(
val id: String = "",
val audioCollectionId: String? = null,
val audioOrder: Int = 1,
val audioName: String = "",
val audioImg: String? = null,
val audioBrief: String? = null,
val audioAuthor: String = "",
val audioUrl: String = "",
)
我们获取音频数据后,将列表解析为由id
为key
的映射,然后将该映射传入播放器在各个平台的实现进行播放
其实这里更加好的处理的方式是,不让平台播放器感知到列表的存在,因为对于音频的播放模式调整以及播放列表的音频增减/切换等操作其实逻辑对于不同平台都是一样,应该抽象出来,放在公共代码当中。但是如果这样话,对于【播放器播放完成后自动切换下一个】这个基础功能就需要额外状态控制,不够美观,权衡下还是将列表置于播放器实现内
用户界面
用户界面不重要,这里简单写一个带进度条的组件,如果希望参考更多详细用户界面,可以在底部源码中查看:
@Composable
fun MusicPlayItem(
item: AudioSimpleModel?,
isPlaying: Boolean,
onPause: () -> Unit,
onPlay: () -> Unit,
toPlaying: () -> Unit,
) {
if (null == item) return
Row(
modifier = Modifier
.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 0.dp)
) {
Image(
painter = painterResource(Res.drawable.nezuko),
contentDescription = null,
modifier = Modifier
.weight(0.15f)
.align(Alignment.CenterVertically)
.clip(RoundedCornerShape(15.dp))
.border(
border = BorderStroke(2.dp, MaterialTheme.colorScheme.onBackground),
shape = RoundedCornerShape(15.dp)
)
)
Column(
modifier = Modifier.weight(0.65f)
.align(Alignment.CenterVertically)
.padding(start = 20.dp)
) {
Text(
modifier = Modifier.padding(start = 2.dp, bottom = 3.dp),
text = item.audioName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
modifier = Modifier.padding(start = 3.dp, bottom = 3.dp),
text = item.audioAuthor,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.subTextColor,
)
}
Row(
modifier = Modifier.weight(0.2f)
.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
) {
Icon(
imageVector = FontAwesomeIcons.Regular.DotCircle,
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
toPlaying()
}
.padding(horizontal = 2.dp)
.size(25.dp)
.padding(2.dp),
tint = MaterialTheme.colorScheme.onBackground
)
Icon(
imageVector = if (isPlaying) vectorResource(Res.drawable.media_pause)
else vectorResource(Res.drawable.media_play),
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable {
if (isPlaying) {
onPause()
} else {
onPlay()
}
}
.size(28.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
其中item
来源于前文处理过的音频映射,使用koin
注入当前父组件:
@Composable
fun MainMusicsScreen(
screenModel: MusicScreenModel = koinInject(),
mainModel: MainScreenModel = koinInject(),
) {
//...
val musicPlayMap = screenModel.musicPlayMap.collectAsState().value
//...
}
对这方面不是很了解的小伙伴,建议先阅读前文Kotlin Compose Multiplatform下导航解决方案
状态维护
音乐控制播放对象单例如下:
class MusicScreenModel : ScreenModel {
private val _playerState = MutableStateFlow(MusicPlayerState())
val playerState = _playerState.asStateFlow()
private val _player = MutableStateFlow(AudioPlayer(_playerState.value))
private val _musicPlayMap = MutableStateFlow<Map<String, AudioSimpleModel>>(emptyMap())
val musicPlayMap = _musicPlayMap.asStateFlow()
suspend fun updateAllAudioList() {
if (_musicPlayMap.value.isNotEmpty()) {
return
}
_musicPlayMap.value = BaseApi().getAllAudio().associateBy { it.id }
}
fun getCurrentMusicData() =
_musicPlayMap.value.getOrDefault(_playerState.value.currentPlayId, AudioSimpleModel())
fun nextPlayModel() {
_playerState.value.playModel = _playerState.value.playModel
.plus(1).rem(MusicPlayModel.entries.size)
}
fun onStart(
playListId: String,
musicPlayMap: Map<String, AudioSimpleModel> = _musicPlayMap.value
) {
if (playListId == _playerState.value.currentPlayId) {
return
}
//todo auth check
if (musicPlayMap.isNotEmpty()) {
_player.value.clearSongs()
_player.value.addSongList(musicPlayMap)
_musicPlayMap.value = musicPlayMap
}
_player.value.start(playListId)
}
fun onPlay() {
_player.value.play()
}
fun onPause() {
_player.value.pause()
}
fun onSeek(time: Double) {
_player.value.seekTo(time)
}
fun onNext() {
_player.value.next()
}
fun onPrev() {
_player.value.prev()
}
}
在首次进入页面时候,调用updateAllAudioList
异步加载服务端音频数据,在用户点击播放时,调用onStart
按需初始化播放器列表等相关数据。这样公共部分的处理就基本完成了
桌面模块
桌面这里我们使用JavaFx
中media
模块实现
桌面依赖引用
val os: OperatingSystem = OperatingSystem.current()
val platform = when {
os.isWindows -> "win"
os.isMacOsX -> "mac"
else -> "linux"
}
val jdkVersion = "17"
kotlin {
sourceSets {
desktopMain.dependencies {
implementation("org.openjfx:javafx-base:$jdkVersion:${platform}")
implementation("org.openjfx:javafx-graphics:$jdkVersion:${platform}")
implementation("org.openjfx:javafx-media:$jdkVersion:${platform}")
implementation("org.openjfx:javafx-swing:$jdkVersion:${platform}")
}
}
}
桌面实现
首先我们需要在主函数中先将JavaFx
初始化:
fun main() {
//do something...
JFXPanel()
application {
//do something...
}
然后在具体实现中,直接使用media
相关函数即可:
actual class AudioPlayer actual constructor(private val musicPlayerState: MusicPlayerState) {
private var media: Media? = null
private var mediaPlayer: MediaPlayer? = null
private var currentItemIndex = -1
private val maxPlaySize: Int = 50;
private val playedItems = mutableListOf<AudioSimpleModel>()
private val mediaItems = mutableMapOf<String, AudioSimpleModel>()
actual fun start(id: String) {
//check
if (!mediaItems.containsKey(id)) return
currentItemIndex = mediaItems.keys.indexOf(id)
playWithIndex(currentItemIndex)
}
actual fun play() {
if (musicPlayerState.isPlaying) return
try {
musicPlayerState.isPlaying = true
mediaPlayer?.play()
} catch (ex: Exception) {
ex.printStackTrace()
}
}
actual fun pause() {
if (!musicPlayerState.isPlaying) return
try {
musicPlayerState.isPlaying = false
mediaPlayer?.pause()
} catch (ex: Exception) {
ex.printStackTrace()
}
}
actual fun next() {
when (musicPlayerState.playModel) {
MusicPlayModel.ORDER.ordinal -> {
currentItemIndex = currentItemIndex.plus(1).rem(mediaItems.size)
}
MusicPlayModel.RANDOM.ordinal -> {
currentItemIndex = Random.nextInt(mediaItems.size)
}
MusicPlayModel.CIRCULATION.ordinal -> {
}
else -> {}
}
playWithIndex(currentItemIndex)
}
actual fun prev() {
if (playedItems.isEmpty()) return
val lastItem = playedItems.removeLast()
currentItemIndex = mediaItems.keys.indexOf(lastItem.id)
playWithIndex(currentItemIndex, false)
}
actual fun seekTo(time: Double) {
musicPlayerState.currentTime = time
mediaPlayer?.seek(Duration.seconds(time))
}
actual fun addSongList(songs: Map<String, AudioSimpleModel>) {
mediaItems += songs
}
actual fun clearSongs() {
mediaItems.clear()
}
actual fun cleanUp() {
musicPlayerState.toBack()
mediaPlayer?.stop()
}
private fun playWithIndex(index: Int, maintainLast: Boolean = true) {
if (index >= mediaItems.size || index < 0) return
//maintain played map
if (maintainLast) {
val lastItem = mediaItems[musicPlayerState.currentPlayId]
if (null != lastItem) {
playedItems.add(lastItem)
if (playedItems.size > maxPlaySize) {
playedItems.removeFirstOrNull()
}
}
}
//convert
val currentItem = mediaItems.entries.toList()[index]
musicPlayerState.currentPlayId = currentItem.key
val playUrl = currentItem.value.audioUrl
//close
musicPlayerState.toBack()
mediaPlayer?.stop()
//start
val thisUrl = URL(playUrl)
media = Media(thisUrl.toString())
mediaPlayer = MediaPlayer(media)
mediaPlayer?.statusProperty()?.addListener { _, oldStatus, newStatus ->
if (newStatus === MediaPlayer.Status.READY
&& mediaPlayer?.totalDuration?.toSeconds()?.isNaN() != true
) {
musicPlayerState.totalDuration =
mediaPlayer?.totalDuration?.toSeconds() ?: Short.MAX_VALUE.toDouble()
play()
}
if (newStatus === MediaPlayer.Status.STALLED) {
musicPlayerState.isBuffering = true
}
if (oldStatus === MediaPlayer.Status.STALLED
&& newStatus !== MediaPlayer.Status.STALLED
) {
musicPlayerState.isBuffering = false
}
}
mediaPlayer?.currentTimeProperty()?.addListener { _, _, newTime ->
if (newTime.toSeconds() > musicPlayerState.currentTime + 1) {
musicPlayerState.currentTime = newTime.toSeconds()
}
}
mediaPlayer?.onEndOfMedia = Runnable {
next()
}
}
}
利用MediaPlayer::statusProperty::addListener
的监听实现对于进度条的初始化以及加载情况的判断,利用MediaPlayer::currentTimeProperty::addListener
实现进度条的自动更新,使用MediaPlayer::onEndOfMedia
完成音频的自动切换
安卓模块
安卓这里我们还是使用常用的exoplayer
进行处理
安卓依赖引用
kotlin {
sourceSets {
desktopMain.dependencies {
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-exoplayer-dash:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
}
}
}
安卓实现
和桌面端类似:
actual class AudioPlayer actual constructor(
private val musicPlayerState: MusicPlayerState,
) : Runnable {
private val handler = Handler(Looper.getMainLooper())
private val maxPlaySize: Int = 50;
private val playedItems = mutableListOf<AudioSimpleModel>()
private val mediaItems = mutableMapOf<String, AudioSimpleModel>()
private var currentItemIndex = -1
private val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_IDLE -> {
}
Player.STATE_BUFFERING -> {
musicPlayerState.isBuffering = true
}
Player.STATE_ENDED -> {
if (musicPlayerState.isPlaying) {
next()
}
}
Player.STATE_READY -> {
musicPlayerState.isBuffering = false
musicPlayerState.totalDuration = (mediaPlayer.duration / 1000).toDouble()
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
musicPlayerState.isPlaying = isPlaying
if (isPlaying) scheduleUpdate() else stopUpdate()
}
}
init {
mediaPlayer.addListener(listener)
mediaPlayer.prepare()
val context = MainActivity.mainContext!!
val intent = Intent(context, MediaPlaybackService::class.java)
ContextCompat.startForegroundService(context, intent)
}
actual fun start(id: String) {
//check
if (!mediaItems.containsKey(id)) return
currentItemIndex = mediaItems.keys.indexOf(id)
playWithIndex(currentItemIndex)
}
actual fun play() {
if (musicPlayerState.isPlaying) return
mediaPlayer.play()
}
actual fun pause() {
if (!musicPlayerState.isPlaying) return
mediaPlayer.pause()
}
actual fun next() {
when (musicPlayerState.playModel) {
MusicPlayModel.ORDER.ordinal -> {
currentItemIndex = currentItemIndex.plus(1).rem(mediaItems.size)
}
MusicPlayModel.RANDOM.ordinal -> {
currentItemIndex = Random.nextInt(mediaItems.size)
}
MusicPlayModel.CIRCULATION.ordinal -> {
}
else -> {}
}
playWithIndex(currentItemIndex)
}
actual fun prev() {
if (playedItems.isEmpty()) return
val lastItem = playedItems.removeLast()
currentItemIndex = mediaItems.keys.indexOf(lastItem.id)
playWithIndex(currentItemIndex, false)
}
actual fun seekTo(time: Double) {
musicPlayerState.currentTime = time
if (musicPlayerState.totalDuration - musicPlayerState.currentTime < 1) {
next()
} else {
mediaPlayer.seekTo((time * 1000).toLong())
}
}
@OptIn(UnstableApi::class)
actual fun addSongList(songs: Map<String, AudioSimpleModel>) {
mediaItems += songs
}
actual fun cleanUp() {
mediaPlayer.release()
mediaPlayer.removeListener(listener)
}
actual fun clearSongs() {
mediaItems.clear()
}
override fun run() {
musicPlayerState.currentTime = (mediaPlayer.currentPosition / 1000).toDouble()
handler.postDelayed(this, 500)
}
private fun stopUpdate() {
handler.removeCallbacks(this)
}
private fun scheduleUpdate() {
stopUpdate()
handler.postDelayed(this, 100)
}
private fun playWithIndex(index: Int, maintainLast: Boolean = true) {
if (index >= mediaItems.size || index < 0) return
//maintain played map
if (maintainLast) {
val lastItem = mediaItems[musicPlayerState.currentPlayId]
if (null != lastItem) {
playedItems.add(lastItem)
if (playedItems.size > maxPlaySize) {
playedItems.removeFirstOrNull()
}
}
}
//convert
val currentItem = mediaItems.entries.toList()[index]
musicPlayerState.currentPlayId = currentItem.key
val playUrl = currentItem.value.audioUrl
//start
val playItem = MediaItem.fromUri(playUrl)
mediaPlayer.setMediaItem(playItem)
mediaPlayer.play()
}
}
利用Player::Listener
其中onPlaybackStateChanged
对于状态的监听,完成进度条的初始化和播放完成的切换以及音频加载情况的判断。使用onIsPlayingChanged
完成对于进度条的更新,这里我们使用private val handler = Handler(Looper.getMainLooper())
创建了一个循环线程的处理,用于获取当前播放位置,将状态更新到MusicPlayerState
籍此传播到用户界面
对于移动端而言,不仅仅需要考虑播放本身,我们还需要在后台保存工作进程,不至于被杀死。需要继承android.app.Service
在创建时构建android.app.Notification
并将其置于前台,简单示例如下:
class MediaPlaybackService : Service() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
}
override fun onDestroy() {
mediaPlayer.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
private fun createNotificationChannel() {
val name = "Player Service"
val descriptionText = "Service for playing media"
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Media Player")
.setContentText("Playing media")
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
companion object {
private const val CHANNEL_ID = "PlayerChannel"
private const val NOTIFICATION_ID = 1
}
}
当然,此时需要同步修改AndroidManifest.xml
的部分内容,添加service
:
<service
android:name="biz.MediaPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
以及添加权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
IOS模块
本站不提供封闭系统的开发相关内容,如果有IOS
模块音频播放处理需求,参考MultiPlatformPlayer-iosapp