Kotlin Compose Multiplatform下音乐播放解决方案

原文链接

欢迎大家对于本站的访问 - AsterCasc

前言

这里使用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 = "",  
)

我们获取音频数据后,将列表解析为由idkey的映射,然后将该映射传入播放器在各个平台的实现进行播放

其实这里更加好的处理的方式是,不让平台播放器感知到列表的存在,因为对于音频的播放模式调整以及播放列表的音频增减/切换等操作其实逻辑对于不同平台都是一样,应该抽象出来,放在公共代码当中。但是如果这样话,对于【播放器播放完成后自动切换下一个】这个基础功能就需要额外状态控制,不够美观,权衡下还是将列表置于播放器实现内

用户界面

用户界面不重要,这里简单写一个带进度条的组件,如果希望参考更多详细用户界面,可以在底部源码中查看:

@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按需初始化播放器列表等相关数据。这样公共部分的处理就基本完成了

桌面模块

桌面这里我们使用JavaFxmedia模块实现

桌面依赖引用
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

源码

Tomoyo

参考资料

Kotlin Multiplatform

Exoplayer

JavaFx

MultiPlatformPlayer

原文链接

欢迎大家对于本站的访问 - AsterCasc

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值