赞
踩
此项目基于网易云API,使用Compose UI编写而成,项目整体采用MVVM架构,主要实现主题切换(适配深色模式
)、音视频资源播放(Media3-Exoplayer)(其中视频播放对Exoplayer进行了自定义样式、竖屏和横屏切换等处理)、前台服务(通知栏)、歌曲下载、资源评论、歌曲解析、歌词逐行匹配等功能
MagicPlayer
主题
登录
歌曲(Media3-Exoplayer)
视频(Media3-Exoplayer)
下载(Aria)
前台服务
歌单
搜索
评论
收藏
最近播放
播放列表
用户信息
推荐
榜单
Library Name | Description |
---|---|
retrofit、okhttp | 用户网络请求 |
hilt | 用于依赖注入 |
media-exoplayer | 用于音视频播放 |
aria | 用于资源下载 |
coil | 用于网络图片加载 |
pager | 用户多页面切换 |
paging3 | 用户分页加载 |
room | 本地资源存储 |
… | … |
播放组件使用Media3-Exoplayer,通过hilt注入Exoplayer、MediaSession以及NotificationManager等依赖,通过在中间层监听Exoplayer播放状态和通过使用ShareFlow
将所监听的数据转发至需要更新UI的ViewModel
层。
下方通过Hilt提供了AudioAttributes、ExoPlayer、MediaSession、MusicNotificationManager、MusicServiceHandler等依赖,在外部我们只需注入MusicServiceHandler
依赖,便可完成数据监听,并更新UI。在中间层MusicServiceHandler
我们只需注入ExoPlayer
依赖,通过实现其Player.Listener
接口的一系列方法,完成对播放状态以及播放数据的监听
@Provides fun provideAudioAttributes():AudioAttributes = AudioAttributes.Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setUsage(C.USAGE_MEDIA) .build() @OptIn(UnstableApi::class) @Singleton @Provides fun provideMusicExoPlayer( @ApplicationContext context: Context, audioAttributes: AudioAttributes ):ExoPlayer = ExoPlayer.Builder(context) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .setTrackSelector(DefaultTrackSelector(context)) .build() @Provides @Singleton fun provideMediaSession( @ApplicationContext context: Context, player: ExoPlayer, ): MediaSession = MediaSession.Builder(context, player).build() @Provides @Singleton fun provideNotificationManager( @ApplicationContext context: Context, player: ExoPlayer, ): MusicNotificationManager = MusicNotificationManager( context = context, exoPlayer = player ) @Provides @Singleton fun provideServiceHandler( exoPlayer: ExoPlayer, musicUseCase: MusicUseCase, service: MusicApiService ): MusicServiceHandler = MusicServiceHandler( exoPlayer = exoPlayer, musicUseCase = musicUseCase, service = service )
为了避免重复无效网络请求,对歌曲URL进行本地缓存,已经拥有URL的歌曲便不再重复获取URL,直接将其设置为当前播放项,通过MediaMetadata
设置媒体相关信息,便于之后在开启前台通知栏服务时,获取相关信息
private suspend fun replaceMediaItem(index: Int){ if (playlist.isEmpty())return currentPlayIndex = index if (!playlist[currentPlayIndex].isLoading) { //未加载 getMusicUrl(playlist[currentPlayIndex].songID){ url,duration,size-> playlist[currentPlayIndex].url = url playlist[currentPlayIndex].duration = duration playlist[currentPlayIndex].isLoading = true playlist[currentPlayIndex].size = CommonUtil.formatFileSize(size.toDouble()) setMediaItem(playlist[currentPlayIndex]) } }else{ setMediaItem(playlist[currentPlayIndex]) } } private suspend fun setMediaItem(bean: SongMediaBean){ exoPlayer.setMediaItem( MediaItem.Builder() .setUri(bean.url) //播放链接 .setMediaMetadata( MediaMetadata.Builder() .setArtist(bean.artist) //歌手 .setTitle(bean.songName) //歌曲名称 .setSubtitle(bean.artist) // 歌手 .setArtworkUri(bean.cover.toUri()) //封面 .setDescription("${bean.songID}") .build() ).build() ) exoPlayer.prepare() exoPlayer.playWhenReady = true startProgress() _eventFlow.emit(AudioPlayState.CurrentPlayItem(playlist[currentPlayIndex])) _eventFlow.emit(AudioPlayState.Playing(true)) }
通过JOB开启一个协程,并每隔0.5s获取一次当前播放进度,并通过ShareFlow
传递到下游
/**
* 为歌曲播放时,每隔0.5s查询一次当前播放progress,并通知UI进行更新*/
private suspend fun startProgress() = job.run {
while(true){
delay(500L)
_eventFlow.emit(AudioPlayState.Progress(exoPlayer.currentPosition,exoPlayer.duration))
}
}
/**
* 当歌曲暂停时,停止更新progress*/
private suspend fun stopProgress(){
job?.cancel()
_eventFlow.emit(AudioPlayState.Playing(false))
}
每次APP首次加载时,将缓存到本地的播放列表项取出存储到进程中,之后的每次数据更新都在进程中的播放列表进行变化,并变更到数据库
fun getNextIndex():Int = (currentPlayIndex + 1) % playlist.size fun getPriorIndex(): Int = if (currentPlayIndex <= 0) playlist.size - 1 else (currentPlayIndex - 1) % playlist.size /** * 切换播放列表下一首*/ private suspend fun next(){ if (playlist.isNotEmpty()){ val next = getNextIndex() replaceMediaItem(next) }else{ currentPlayIndex = -1 } } /** * 切换播放列表上一首*/ private suspend fun prior(){ if (playlist.isNotEmpty()){ val prior = getPriorIndex() replaceMediaItem(prior) }else{ currentPlayIndex = -1 } }
在需要响应数据的ViewModel
层,只需注入MusicServiceHandler
依赖即可,并对其传递的事件进行监听,并根据事件状态,做出不同的处理,在ViewModel从对各数据值通过mutableStateOf
封装在一个data class
中,并绑定至Composable
函数中,当ViewModel
值的状态发生改变时,UI界面及时响应变更并更新UI
private fun playerStatus(){ viewModelScope.launch(Dispatchers.IO) { musicServiceHandler.eventFlow.collect { when(it){ is AudioPlayState.Ready->{ _uiStatus.value = uiStatus.value.copy( totalDuration = transformTime(it.duration) ) } is AudioPlayState.Buffering->{ calculateProgress(it.progress,it.duration) } is AudioPlayState.Playing->{ _uiStatus.value = uiStatus.value.copy( isPlaying = it.isPlaying ) } is AudioPlayState.Progress->{ calculateProgress(it.progress,it.duration) val line = matchLyric(it.progress) _uiStatus.value = _uiStatus.value.copy( currentLine = line ) } is AudioPlayState.CurrentPlayItem->{ if (it.bean != null){ _uiStatus.value = uiStatus.value.copy( artist = it.bean.artist, name = it.bean.songName, cover = it.bean.cover, musicID = it.bean.songID, totalDuration = transformTime(it.bean.duration) ) //同步更新数据库 musicUseCase.updateUrl(it.bean.songID,it.bean.url) musicUseCase.updateLoading(it.bean.songID, true) musicUseCase.updateDuration(it.bean.songID, it.bean.duration) musicUseCase.updateSize(it.bean.songID, it.bean.size) } } is AudioPlayState.Reenter->{ if (it.bean != null){ _uiStatus.value = uiStatus.value.copy( artist = it.bean.artist, name = it.bean.songName, cover = it.bean.cover, musicID = it.bean.songID, totalDuration = transformTime(it.bean.duration) ) } } is AudioPlayState.NetworkFailed->{ _eventFlow.emit(MusicPlayerStatus.NetworkFailed(it.msg)) } } } } }
此项目采用的是歌词逐行解析,首先了解一下lrc
歌词格式
[00:18.466]今天我 寒夜里看雪飘过
分别代表[分:秒:毫秒]内容
逐行歌词解析主要采用两个正则表达式:一个将所有歌词拆分成行的形式,一个解析每一行的内容
其中“(.+)”是匹配任意长度字符,"\\d"是匹配0-9任一数字,“\\d{2,3}”是匹配2位或者3位数字
private val PATTERN_LINE = Pattern.compile("((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)")
private val PATTERN_TIME = Pattern.compile("\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]")
通过\\n
将歌词解析成数行,此处\\
为转义字符,实为\
,故\\n
为\n
,意味换行符。然后对每一行歌词进行解析
fun parseLyric(lrcText: String): List<LyricBean>? { if (lrcText.isEmpty()) { return null } val entityList: MutableList<LyricBean> = ArrayList<LyricBean>() // 以换行符为分割点 val array = lrcText.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() for (line in array) { // 循环遍历按行解析 val list: List<LyricBean>? = parseLine(line) list?.let { entityList.addAll(it) } } // 以时间为基准,从小到大排列 entityList.sortBy { it.time } return entityList }
由于此处部分歌曲的歌词URL并未严格遵守lrc格式,部分歌曲歌词首部作者信息等使用JSON字符进行返回,所有在对每一行进行解析时,对此情况进行JSON处理,然后解析添加到歌词列表中。余下,便是常规lrc正则表达式判定,并读取其中的数据
/** * 解析每一句歌词 * 其中头部和尾部存在歌手、编曲等JSON信息 * 中间为标准LRC歌词格式 * @param line */ private fun parseLine(line: String): List<LyricBean>? { var newLine = line val entryList: MutableList<LyricBean> = ArrayList<LyricBean>() if (newLine.isEmpty()) { return null } // 去除空格 newLine = line.trim { it <= ' ' } /** * 作者等信息: * [{"t":0,"c":[{"tx":"作词: "},{"tx":"黄家驹","li":"http://p1.music.126.net/2rERC5bz1BD0GZrU06saTw==/109951166629360845.jpg","or":"orpheus://nm/artist/home?id=189688&type=artist"}]}, * {"t":1000,"c":[{"tx":"作曲: "},{"tx":"黄家驹","li":"http://p1.music.126.net/2rERC5bz1BD0GZrU06saTw==/109951166629360845.jpg","or":"orpheus://nm/artist/home?id=189688&type=artist"}]}, * {"t":2000,"c":[{"tx":"编曲: "},{"tx":"Beyond"},{"tx":"/"},{"tx":"梁邦彦"}]}, * {"t":3000,"c":[{"tx":"制作人: "},{"tx":"Beyond"},{"tx":"/"},{"tx":"梁邦彦"}]}, * {"t":271852,"c":[{"tx":"录音: "},{"tx":"Shunichi Yokoi"}]}] * */ /*** * 歌词和时间:[00:18.466]今天我 寒夜里看雪飘过 * */ val lineMatcher: Matcher = PATTERN_LINE.matcher(newLine) // 正则表达式,判断line中是否包含“[00:00.00]xxx”格式的内容" // 如果没有,则为JSON字符串 try { if (!lineMatcher.matches()) { if (!PATTERN_TIME.matcher(newLine).matches()){ //解析作者等信息 val infoBean = GsonFormat.fromJson(newLine,LyricAuthorBean::class.java) var content = "" infoBean.c.forEach { //将所有信息组成一行 content += it.tx } entryList.add(LyricBean(infoBean.t,content)) }else{ //某一行歌词只包含“[00:00.00]”内容,不包含文字,则不进行处理 return null } } }catch (e:Exception){ println(e.message) return null } // 获取文本内容 val text: String? = lineMatcher.group(3) // 获取时间标签 val times: String? = lineMatcher.group(1) val timeMatcher: Matcher? = times?.let { PATTERN_TIME.matcher(it) } if (timeMatcher != null) { //将时间转为毫秒级 while (timeMatcher.find()) { val min: Long = timeMatcher.group(1)?.toLong() ?:0L // 分 val sec: Long = timeMatcher.group(2)?.toLong() ?:0L // 秒 val mil: Long = timeMatcher.group(3)?.toLong() ?:0L // 毫秒 val time: Long = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil * 10 entryList.add(LyricBean(text = text ?: "", time = time)) } } return entryList }
视频播放依旧使用的是Media3-Exoplayer
组件,相对于音频资源播放,需要稍加封装。此项目对Exoplayer进行了自定义样式处理、竖屏和横屏切换处理、通知栏媒体样式前台服务处理等。视频播放分为MV和MLOG两种类型,所衍生出两个不同UI的界面,其中播放逻辑基本一致,此处便以其中一处作为讲解示例
在Compose中还并未有PlayerView
对应的组件,所有需要通过AndroidView
进行引入,其中factory
为初始化组件参数,update
为当状态发生变化,导致发生重组时,更新相对应的数据。其中useController = false
意味不使用其自带的控件,例如播放、暂停、进度条等
AndroidView( factory = { context-> PlayerView(context).apply { viewModel.mediaController.value useController = false layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) } }, update = { if (it.player == null)it.player = viewModel.mediaController.value when(lifecycle.value){ Lifecycle.Event.ON_STOP-> { it.onPause() it.player?.stop() } Lifecycle.Event.ON_PAUSE-> { it.onPause() it.player?.pause() } Lifecycle.Event.ON_RESUME-> it.onResume() else-> Unit } }, modifier = Modifier .fillMaxWidth() .aspectRatio(16 / 9f) .clickable { viewModel.onPlayEvent(MvPlayerEvent.ShowControlPanel) } .background(MagicMusicTheme.colors.black) .constrainAs(playerRes){ start.linkTo(parent.start) end.linkTo(parent.end) top.linkTo(parent.top) } )
通过useController = false
不使用自带的控件后,将播放控件分为竖屏和横屏两种状态,并通过AnimatedVisibility
进行显示与隐藏,具体的代码便不在贴出,可以点击文末项目链接进行浏览。总体思路便是不使用自带的控件,然后将自己需要的控件样式与AndroidView引入的Exoplayer进行组合
ConstraintLayout( modifier = Modifier .fillMaxSize() .background(MagicMusicTheme.colors.background) .statusBarsPadding() .navigationBarsPadding() ){ val (playerRes,controlRes,similarRes) = createRefs() AndroidView( factory = { context-> PlayerView(context).apply { //省略不必要代码... } }, update = { //省略不必要代码... } ) //竖屏播放控件 PlayerControls( isPlaying = value.isPlaying, isVisible = value.isVisibility && !value.isFullScreen, progress = value.progress, currentPosition = value.currentPosition, bean = value.mvInfo, onBack = onBack, onChangeProgress = { viewModel.onPlayEvent(MvPlayerEvent.ChangeProgress(it)) }, onPlayOrPause = { viewModel.onPlayEvent(MvPlayerEvent.PlayOrPause) }, onFullScreen = { viewModel.onPlayEvent(MvPlayerEvent.FullScreen) }, modifier = Modifier .fillMaxWidth() .constrainAs(controlRes){ start.linkTo(parent.start) end.linkTo(parent.end) top.linkTo(playerRes.top) bottom.linkTo(playerRes.bottom) } ) AnimatedVisibility( visible = !value.isFullScreen, enter = EnterTransition.None, exit = ExitTransition.None, modifier = Modifier.constrainAs(similarRes){ start.linkTo(parent.start) end.linkTo(parent.end) top.linkTo(playerRes.bottom) } ){ LazyColumn( verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 10.dp, top = 5.dp), modifier = Modifier .fillMaxWidth() .background(MagicMusicTheme.colors.background) ){ //省略不必要代码... } } //全屏时的播放控件 AnimatedVisibility( visible = value.isFullScreen && value.isVisibility, enter = EnterTransition.None, exit = ExitTransition.None, ) { if (value.mvInfo != null){ FullScreenControl( progress = value.progress, currentPosition = value.currentPosition, title = value.mvInfo.name, duration = value.mvInfo.duration.toLong(), isPlaying = value.isPlaying, onExitFullScreen = { viewModel.onPlayEvent(MvPlayerEvent.FullScreen) }, onPlayOrPause = { viewModel.onPlayEvent(MvPlayerEvent.PlayOrPause) }, onChangeProgress = { viewModel.onPlayEvent(MvPlayerEvent.ChangeProgress(it)) }, onShowControl = { viewModel.onPlayEvent(MvPlayerEvent.ShowControlPanel) } ) } } } }
首先在manifest
的Activity中添加如下属性,包括对键盘、屏幕方向、屏幕大小的一些配置
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
因为我使用的是单Activity模式,故我讲屏幕旋转逻辑放在MainActivity
中,暴露外部一个方法进行调用即可。由于此方法需要一个Context
上下参数,故设置了一个懒加载的MainActivity
上下文,然后在onCreate中初始化parentThis = this
。其中activity.requestedOrientation = orientation
语句为完成屏幕旋转的关键,剩下的便是对系统状态栏和导航栏的隐藏和显示逻辑处理
companion object{ lateinit var parentThis:MainActivity fun Context.setScreenOrientation(orientation: Int) { val activity = this.findActivity() ?: return activity.requestedOrientation = orientation if (orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { hideSystemUi() } else { showSystemUi() } } private fun Context.hideSystemUi() { val activity = this.findActivity() ?: return val window = activity.window ?: return WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).let { controller -> controller.hide(WindowInsetsCompat.Type.systemBars()) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } private fun Context.showSystemUi() { val activity = this.findActivity() ?: return val window = activity.window ?: return WindowCompat.setDecorFitsSystemWindows(window, true) WindowInsetsControllerCompat( window, window.decorView ).show(WindowInsetsCompat.Type.systemBars()) } private fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() else -> null } }
在ViewModel中响应的横竖屏按钮切换事件处理,便可以直接引用上述暴露的方法,并在最后变更当前屏幕状态,让UI界面进行重组
with(MainActivity.parentThis){
if (_uiState.value.isFullScreen){
//纵向
setScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}else{
//横向
setScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
}
}
_uiState.value = uiState.value.copy(
isFullScreen = !_uiState.value.isFullScreen
)
歌曲下载采用Aria Library
实现多任务下载,并实现前台服务下载,在通知栏显示下载进度。在外部开启下载服务,通过startService
方式启动,并通过将下载回调通过接口进行返回,然后在中间层DownloadHandler
通过bindService
绑定服务,并通过其中的binder
获取当前service,然后实现接返回的接口,并通过ShareFlow
传递至下游的ViewModel。
fun setDownloadListener(listener: DownloadListener){
this.listener = listener
}
private fun onDownloadListener(task: DownloadTask,msg:String){
if (this::listener.isInitialized){
listener.onDownloadState(task,msg)
}
}
下列为实现DownloadTaskListener
的一系列接口,对不同的下载状态进行处理,然后将处理结果通过onDownloadListener
进行回调至中间层
/** * 任务预加载*/ override fun onPre(task: DownloadTask?) { if (task != null){ onDownloadListener(task,"") } } /** * 任务预加载完成*/ override fun onTaskPre(task: DownloadTask?) { if (task != null){ onDownloadListener(task,"") } } /** * 等待中*/ override fun onWait(task: DownloadTask?) { if (task != null){ onDownloadListener(task,"") } } /** * 开始下载 */ override fun onTaskStart(task:DownloadTask?){ if (task != null){ onDownloadListener(task,"") } } /** * 下载暂停 */ override fun onTaskStop(task:DownloadTask?){ if (task != null){ onDownloadListener(task,"") } } /** * 下载恢复 */ override fun onTaskResume(task:DownloadTask?){ if (task != null){ onDownloadListener(task,"") } } /** * 下载中 */ @RequiresApi(Build.VERSION_CODES.O) override fun onTaskRunning(task:DownloadTask?){ if (task != null){ task.convertFileSize val progress = (task.currentProgress * 100 / task.fileSize).toInt() notification.setProgress(progress) onDownloadListener(task,"") } } /** * 任务不支持断点*/ override fun onNoSupportBreakPoint(task: DownloadTask?) { if (task != null){ onDownloadListener(task,"") } } /** * 下载完成 */ override fun onTaskComplete(task:DownloadTask?){ if (task != null){ val completeList = Aria.download(this).allCompleteTask val unCompleteList = Aria.download(this).allNotCompleteTask if (completeList != null && unCompleteList != null && completeList.isNotEmpty() && unCompleteList.isEmpty()){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ stopForeground(Service.STOP_FOREGROUND_DETACH) isForegroundSuc = false } //下载任务全部完成,结束service stopSelf() } onDownloadListener(task,"") } } /** * 下载失败 */ override fun onTaskFail(task: DownloadTask?, e: Exception?){ if (task != null){ onDownloadListener(task,e?.message.toString()) } } /** * 取消下载 */ override fun onTaskCancel(task:DownloadTask?){ if (task != null){ onDownloadListener(task,"") } }
在中间层DownloadHandler
需要创建下载文件夹,对需求下载的内容进行查重,判断其是否已经被下载,如若已经下载,便不在重复下载、下载状态处理、以及读写权限处理等。下列是对Service中的接口进行监听,并通过将监听的数据处理后,通过ShareFlow
分发至下游
@OptIn(DelicateCoroutinesApi::class) private fun downloadListener(downloadService: DownloadService) { downloadService.setDownloadListener(object : DownloadListener { override fun onDownloadState(task: DownloadTask,msg:String) { val index = searchIndex(task.key) if (index == -1) return GlobalScope.launch(Dispatchers.Main) { when (task.state) { IEntity.STATE_PRE -> { downloadList[index].taskID = task.entity.id downloadUseCase.updateTaskID( musicID = downloadList[index].musicID, taskID = task.entity.id ) _eventFlow.emit(DownloadStateFlow.Prepare(task,index)) } IEntity.STATE_WAIT -> { _eventFlow.emit(DownloadStateFlow.Prepare(task,index)) } IEntity.STATE_RUNNING -> { _eventFlow.emit(DownloadStateFlow.Running(task,index)) } IEntity.STATE_STOP -> { _eventFlow.emit(DownloadStateFlow.Stop(task,index)) } IEntity.STATE_CANCEL -> { downloadList.removeAt(index) _eventFlow.emit(DownloadStateFlow.Cancel(task,index)) } IEntity.STATE_COMPLETE -> { downloadList[index].download = true downloadUseCase.updateDownloadState( musicID = downloadList[index].musicID, download = true ) Aria.download(this).load(task.entity.id).removeRecord() _eventFlow.emit(DownloadStateFlow.Complete(task,index)) } IEntity.STATE_FAIL -> { _eventFlow.emit(DownloadStateFlow.Fail(task,index,msg)) } } } } }) }
在此项目中前台服务通知栏分为媒体资源和下载两种样式,其中媒体资源的音频和视频服务启动方式不一样,音频采用startService
启动,视频则采用MediaControl
,其内部自带服务启动,只需对其进行相对应初始化即可;下载则是采用startService
和bindService
混合启动模式,即两种都使用
由于音频服务和视频服务都继承MediaSessionService
,不同之处在于启动方式和依赖注入,故此处以音频服务为例。
文章顶部已经介绍了hilt依赖注入,此处便不在重复,直接通过@Inject
注入所需依赖,然后外部通过startService
启动服务后,在onStartCommand
中构建通知栏
@AndroidEntryPoint class MusicService:MediaSessionService() { @Inject lateinit var mediaSession: MediaSession @Inject lateinit var notificationManager: MusicNotificationManager override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.startNotificationService( mediaSession = mediaSession, mediaSessionService = this ) } return super.onStartCommand(intent, flags, startId) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession = mediaSession override fun onDestroy() { super.onDestroy() mediaSession.apply { release() if (player.playbackState != Player.STATE_IDLE) { player.seekTo(0) player.playWhenReady = false player.stop() } } } }
在Android 8.0之后开启的通知栏需要建立Channel
,其中setMediaDescriptionAdapter
为设置通知栏显示的相关信息,此部分来源于当前播放项,也就是文章之前提过的MediaItem
中获取
class MusicNotificationManager @Inject constructor( @ApplicationContext private val context: Context, private val exoPlayer: ExoPlayer ) { private val NOTIFICATION_ID = 1 private val NOTIFICATION_CHANNEL_NAME = "Music Notification channel" private val NOTIFICATION_CHANNEL_ID = "Music Notification channel id" private var notificationManager = NotificationManagerCompat.from(context) init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createNotificationChannel() } } @RequiresApi(Build.VERSION_CODES.O) fun startNotificationService( mediaSessionService: MediaSessionService, mediaSession: MediaSession, ){ buildNotification(mediaSession) startForegroundNotificationService(mediaSessionService) } @RequiresApi(Build.VERSION_CODES.O) private fun startForegroundNotificationService(mediaSessionService: MediaSessionService){ val notification = Notification.Builder(context, NOTIFICATION_CHANNEL_ID) .setCategory(Notification.CATEGORY_SERVICE) .build() mediaSessionService.startForeground(NOTIFICATION_ID, notification) } @OptIn(UnstableApi::class) private fun buildNotification(mediaSession: MediaSession){ PlayerNotificationManager.Builder( context, NOTIFICATION_ID, NOTIFICATION_CHANNEL_ID ).setMediaDescriptionAdapter( MusicNotificationAdapter( context = context, pendingIntent = mediaSession.sessionActivity ) ) .setSmallIconResourceId(R.drawable.magicmusic_logo) //通知栏的小图标 .build() .apply { setMediaSessionToken(mediaSession.sessionCompatToken) setUseFastForwardActionInCompactView(true) setUseRewindActionInCompactView(true) setUseNextActionInCompactView(true) setPriority(NotificationCompat.PRIORITY_DEFAULT) setPlayer(exoPlayer) } } @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(){ val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT, ) notificationManager.createNotificationChannel(channel) } }
由于音乐cover为URL,需要在通知栏显示,需要将其转化为bitmap,下列getBitmap
方法启动一个协程并使用coil
将url转为bitmap并通过函数返回,然后在getCurrentLargeIcon
方法中设置bitmap即可,其他的title、subTitle等信息便可以直接设置
@UnstableApi class MusicNotificationAdapter( private val context: Context, private val pendingIntent: PendingIntent?, ):PlayerNotificationManager.MediaDescriptionAdapter { /** * 通知栏中歌曲的封面、名称、作者等信息*/ override fun getCurrentContentTitle(player: Player): CharSequence { return player.mediaMetadata.title ?: "Unknown" } override fun createCurrentContentIntent(player: Player): PendingIntent? = pendingIntent override fun getCurrentContentText(player: Player): CharSequence { return player.mediaMetadata.subtitle ?: "Unknown" } override fun getCurrentLargeIcon( player: Player, callback: PlayerNotificationManager.BitmapCallback ): Bitmap? { getBitmap( url = player.mediaMetadata.artworkUri, //此字段内容为约定而使 onSuccess = { callback.onBitmap(it) }, onError = { } ) return null } @OptIn(DelicateCoroutinesApi::class) private fun getBitmap( url:Uri?, onSuccess:(Bitmap)->Unit, onError:(String)->Unit ){ var bitmap:Bitmap? = null val scope = GlobalScope.launch(Dispatchers.Main){ val request = ImageRequest.Builder(context = context) .data(url) .allowHardware(false) .build() val result = context.imageLoader.execute(request) if (result is SuccessResult){ bitmap = (result.drawable as BitmapDrawable).bitmap }else{ cancel("Error Request") } } scope.invokeOnCompletion { bitmap?.let { bitmap-> onSuccess(bitmap) }?:it?.let { onError(it.message.toString()) }?: onError("Unknown Exception") } } }
还需在manifest
中声明此服务
<service
android:name=".route.musicplayer.service.MusicService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
开启下载是通过startService
方式启动,其中通过Binder
返回当前Service
对象,开启下载服务后,在onStartCommand
中解析下载信息,然后开启前台服务。值得注意的是,如果明确服务为前台服务,在 Android 8.0 以后可以通过调用 startForegroundService启动前台服务,
它和 startService 的区别在于是它包含一个隐含承诺,即必须在服务启动后尽快调用 startForeground,否则 5s 后服务将停止,且会触发 ANR。所有下来对启动服务进行了处理,让后台计时4.5S,若4.5S之后仍未启动服务,则手动关闭服务,防止发生异常
class DownloadService:Service(),DownloadTaskListener { private lateinit var notification:DownloadNotification private var isForegroundSuc = false private var timerFlag = false private val FOREGROUND_NOTIFY_ID = 1 private lateinit var listener:DownloadListener private var notificationID = 100 private var map:Map<String,Int> = emptyMap() override fun onBind(p0: Intent?): IBinder = DownloadBinder() inner class DownloadBinder:Binder(){ val service:DownloadService get() = this@DownloadService } override fun onCreate() { super.onCreate() initAria() initNotification() } @RequiresApi(Build.VERSION_CODES.O) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent != null){ val url = intent.getStringExtra(Constants.DownloadURL) ?: "" val path = intent.getStringExtra(Constants.DownloadPath) ?: "" val cover = intent.getStringExtra(Constants.DownloadCover) ?: "" val name = intent.getStringExtra(Constants.DownloadName) ?: "Unknown" val taskID = Aria.download(this) .load(url) .setFilePath(path) .create() if (taskID > 0L){ notificationID++ map += url to notificationID startForeground(name,cover) } /** * 如果明确服务一定是前台服务,在 Android 8.0 以后可以调用 startForegroundService, * 它和 startService 的区别是它隐含了一个承诺,必须在服务中尽快调用 startForeground,否则 5s 后服务将停止,且会触发 ANR。*/ if (!timerFlag){ timerFlag = true object :CountDownTimer(4500L,4500L){ override fun onTick(p0: Long) { } override fun onFinish() { if (!isForegroundSuc){ /** * 如果4.5s后没有执行相关操作,则停止服务*/ stopForeground(STOP_FOREGROUND_DETACH) stopSelf() } } }.start() } } return super.onStartCommand(intent, flags, startId) } @RequiresApi(Build.VERSION_CODES.O) private fun startForeground(name: String, cover: String) { if (!isForegroundSuc) { getBitmap( url = cover, onSuccess = { startForeground(FOREGROUND_NOTIFY_ID, notification.createNotification(notificationID,name,it)) isForegroundSuc = true }, onError = { val bitmap = BitmapFactory.decodeResource(APP.context.resources, R.drawable.magicmusic_logo) startForeground(FOREGROUND_NOTIFY_ID, notification.createNotification(notificationID,name,bitmap)) isForegroundSuc = true } ) } } @kotlin.OptIn(DelicateCoroutinesApi::class) private fun getBitmap( url: String?, onSuccess:(Bitmap)->Unit, onError:(String)->Unit ){ var bitmap: Bitmap? = null val scope = GlobalScope.launch(Dispatchers.Main){ val request = ImageRequest.Builder(context = APP.context) .data(url) .allowHardware(false) .build() val result = APP.context.imageLoader.execute(request) if (result is SuccessResult){ bitmap = (result.drawable as BitmapDrawable).bitmap }else{ cancel("Error Request") } } scope.invokeOnCompletion { bitmap?.let { bitmap-> onSuccess(bitmap) }?:it?.let { onError(it.message.toString()) }?: onError("Unknown Exception") } } private fun initAria(){ Aria.download(this).register() Aria.get(this).downloadConfig .setMaxTaskNum(3) .setUseBlock(true) .setConvertSpeed(true) .setUpdateInterval(3000L) } private fun initNotification(){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ notification = DownloadNotification(APP.context) } } override fun onDestroy() { super.onDestroy() Aria.download(this).unRegister() isForegroundSuc = false timerFlag = false stopForeground(STOP_FOREGROUND_DETACH) stopSelf() } //省略... }
在通知栏处,在创建通知栏时,只需设置.setProgress(maxProgress,0,false)
即可出现进度条,然后只需暴露创建通知和刷新下载进度Progress两个方法即可,在服务中通过计算当前下载进度然后调用DownloadNotification
的setProgress
,便可完成通知栏下载进度动态显示
@RequiresApi(Build.VERSION_CODES.O) class DownloadNotification( private val context:Context ) { private val NOTIFICATION_CHANNEL_NAME = "Download Notification channel" private val NOTIFICATION_CHANNEL_ID = "Download Notification channel id" private lateinit var notificationBuilder:NotificationCompat.Builder private lateinit var notificationManager: NotificationManagerCompat private val maxProgress = 100 fun createNotification(id:Int,name: String,bitmap: Bitmap):Notification?{ if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ notificationManager = NotificationManagerCompat.from(context) notificationBuilder = NotificationCompat.Builder(context,NOTIFICATION_CHANNEL_ID.plus(id)) createNotificationChannel(id) return startNotification(id,name, bitmap) } return null } @OptIn(UnstableApi::class) private fun startNotification(id: Int,name: String,bitmap: Bitmap):Notification?{ notificationBuilder .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setSmallIcon(R.drawable.magicmusic_logo) .setAutoCancel(false) .setProgress(maxProgress,0,false) .setContentText(name) .setLargeIcon(bitmap) if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return null } notificationManager.notify(id,notificationBuilder.build()) return notificationBuilder.build() } fun setProgress(id:Int,progress:Int){ if (this::notificationBuilder.isInitialized){ if (progress in 0 until maxProgress){ notificationBuilder.setContentText("${progress}% downloaded") notificationBuilder.setProgress(maxProgress,progress,false) }else if (progress == maxProgress){ notificationBuilder.setContentText("downloaded successful!") notificationBuilder.setAutoCancel(true) }else{ notificationBuilder.setContentText("downloaded failed!") } if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return } notificationManager.notify(id,notificationBuilder.build()) } } @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(id:Int){ val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID.plus(id), NOTIFICATION_CHANNEL_NAME.plus(id), NotificationManager.IMPORTANCE_DEFAULT, ) notificationManager.createNotificationChannel(channel) } }
每一个页面都适配了亮色主题和深色主题,由于篇幅有限,还有些许页面没有做过多解释,下载只对部分功能效果图进行贴出
评论分为歌单评论、专辑评论、歌曲评论、MV评论、MLOG评论等,而每一个功能的评论又分为:资源评论、楼层评论(回复他人的评论)、发送评论、点赞评论几部分
由于篇幅有限,便只贴示部分图片,如若有意,可以点击下方项目链接进行浏览
Github
https://github.com/FranzLiszt-1847/MagicPlayer
Gitee
https://gitee.com/FranzLiszt1847/MagicPlayer
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。