Drop androidx.media3

This commit is contained in:
vfsfitvnm
2022-06-25 16:04:52 +02:00
parent 524bea60d9
commit 7e6b5747a2
24 changed files with 626 additions and 666 deletions

View File

@@ -2,30 +2,28 @@ package it.vfsfitvnm.vimusic.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media.session.MediaButtonReceiver
import androidx.media3.common.*
import androidx.media3.common.util.Util
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
@@ -34,83 +32,63 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.analytics.PlaybackStats
import androidx.media3.exoplayer.analytics.PlaybackStatsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.*
import androidx.media3.session.MediaNotification.ActionFactory
import coil.Coil
import coil.request.ImageRequest
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.roundToInt
import kotlin.system.exitProcess
val StartRadioCommand = SessionCommand("StartRadioCommand", Bundle.EMPTY)
val StartArtistRadioCommand = SessionCommand("StartArtistRadioCommand", Bundle.EMPTY)
val StopRadioCommand = SessionCommand("StopRadioCommand", Bundle.EMPTY)
val GetCacheSizeCommand = SessionCommand("GetCacheSizeCommand", Bundle.EMPTY)
val GetSongCacheSizeCommand = SessionCommand("GetSongCacheSizeCommand", Bundle.EMPTY)
val DeleteSongCacheCommand = SessionCommand("DeleteSongCacheCommand", Bundle.EMPTY)
val SetSkipSilenceCommand = SessionCommand("SetSkipSilenceCommand", Bundle.EMPTY)
val GetAudioSessionIdCommand = SessionCommand("GetAudioSessionIdCommand", Bundle.EMPTY)
val SetSleepTimerCommand = SessionCommand("SetSleepTimerCommand", Bundle.EMPTY)
val GetSleepTimerMillisLeftCommand = SessionCommand("GetSleepTimerMillisLeftCommand", Bundle.EMPTY)
val CancelSleepTimerCommand = SessionCommand("CancelSleepTimerCommand", Bundle.EMPTY)
@ExperimentalAnimationApi
@ExperimentalFoundationApi
class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotification.Provider,
PlaybackStatsListener.Callback, Player.Listener {
companion object {
private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id"
private const val SleepTimerNotificationId = 1002
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
}
class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback {
private lateinit var mediaSession: MediaSessionCompat
private lateinit var cache: SimpleCache
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession
private val stateBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_SEEK_TO
)
private val metadataBuilder = MediaMetadataCompat.Builder()
private lateinit var notificationManager: NotificationManager
private var timerJob: TimerJob? = null
private var notificationThumbnailSize: Int = 0
private var lastArtworkUri: Uri? = null
private var lastBitmap: Bitmap? = null
private var radio: YoutubePlayer.Radio? = null
private var sleepTimerJob: Job? = null
private var sleepTimerRealtime: Long? = null
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
private val songPendingLoudnessDb = mutableMapOf<String, Float?>()
override fun onBind(intent: Intent?) = Binder()
override fun onCreate() {
super.onCreate()
notificationThumbnailSize = (256 * resources.displayMetrics.density).roundToInt()
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
createNotificationChannel()
setMediaNotificationProvider(this)
val cacheEvictor = LeastRecentlyUsedCacheEvictor(preferences.exoPlayerDiskCacheMaxSizeBytes)
cache = SimpleCache(cacheDir, cacheEvictor, StandaloneDatabaseProvider(this))
@@ -132,153 +110,39 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.repeatMode = preferences.repeatMode
player.skipSilenceEnabled = preferences.skipSilence
player.playWhenReady = true
player.addListener(this)
player.addAnalyticsListener(PlaybackStatsListener(false, this))
mediaSession = MediaSession.Builder(this, player)
.withSessionActivity()
.setCallback(this)
.build()
mediaSession = MediaSessionCompat(this, "PlayerService")
mediaSession.setCallback(SessionCallback(player))
mediaSession.setPlaybackState(stateBuilder.build())
mediaSession.isActive = true
}
player.addListener(this)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
notificationManager.cancel(NotificationId)
stopSelf()
}
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
player.removeListener(this)
player.stop()
player.release()
mediaSession.isActive = false
mediaSession.release()
cache.release()
super.onDestroy()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession {
return mediaSession
}
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val sessionCommands = SessionCommands.Builder()
.add(StartRadioCommand)
.add(StartArtistRadioCommand)
.add(StopRadioCommand)
.add(GetCacheSizeCommand)
.add(GetSongCacheSizeCommand)
.add(DeleteSongCacheCommand)
.add(SetSkipSilenceCommand)
.add(GetAudioSessionIdCommand)
.add(SetSleepTimerCommand)
.add(GetSleepTimerMillisLeftCommand)
.add(CancelSleepTimerCommand)
.build()
val playerCommands = Player.Commands.Builder().addAllCommands().build()
return MediaSession.ConnectionResult.accept(sessionCommands, playerCommands)
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand) {
StartRadioCommand, StartArtistRadioCommand -> {
radio = null
YoutubePlayer.Radio(
videoId = args.getString("videoId"),
playlistId = args.getString("playlistId"),
playlistSetVideoId = args.getString("playlistSetVideoId"),
parameters = args.getString("params"),
).let {
coroutineScope.launch(Dispatchers.Main) {
when (customCommand) {
StartRadioCommand -> player.addMediaItems(it.process().drop(1))
StartArtistRadioCommand -> player.forcePlayFromBeginning(it.process())
}
radio = it
}
}
}
StopRadioCommand -> radio = null
GetCacheSizeCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("cacheSize" to cache.cacheSpace)
)
)
}
GetSongCacheSizeCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("cacheSize" to cache.getCachedBytes(
args.getString("videoId") ?: "",
0,
C.LENGTH_UNSET.toLong()
))
)
)
}
DeleteSongCacheCommand -> {
args.getString("videoId")?.let { videoId ->
cache.removeResource(videoId)
}
}
SetSkipSilenceCommand -> {
player.skipSilenceEnabled = args.getBoolean("skipSilence")
}
GetAudioSessionIdCommand -> {
return Futures.immediateFuture(
SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("audioSessionId" to player.audioSessionId)
)
)
}
SetSleepTimerCommand -> {
val delayMillis = args.getLong("delayMillis", 2000)
sleepTimerJob = coroutineScope.launch {
sleepTimerRealtime = SystemClock.elapsedRealtime() + delayMillis
delay(delayMillis)
withContext(Dispatchers.Main) {
val notification = NotificationCompat.Builder(
this@PlayerService,
SleepTimerNotificationChannelId
)
.setContentTitle("Sleep timer ended")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setSmallIcon(R.drawable.app_icon)
.build()
notificationManager.notify(SleepTimerNotificationId, notification)
}
exitProcess(0)
}
}
GetSleepTimerMillisLeftCommand -> {
return Futures.immediateFuture(sleepTimerRealtime?.let {
(SessionResult(
SessionResult.RESULT_SUCCESS,
bundleOf("millisLeft" to it - SystemClock.elapsedRealtime())
))
} ?: SessionResult(SessionResult.RESULT_ERROR_INVALID_STATE))
}
CancelSleepTimerCommand -> {
sleepTimerJob?.cancel()
sleepTimerJob = null
sleepTimerRealtime = null
}
}
return super.onCustomCommand(session, controller, customCommand, args)
}
override fun onPlaybackStatsReady(
eventTime: AnalyticsListener.EventTime,
playbackStats: PlaybackStats
@@ -308,63 +172,62 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
player.volume = player.currentMediaItem?.mediaId?.let { mediaId ->
songPendingLoudnessDb.getOrElse(mediaId) {
player.currentMediaItem?.mediaMetadata?.extras?.getFloat("loudnessDb")
}?.takeIf { it > 0 }?.let { loudnessDb ->
(1f - (0.01f + loudnessDb / 14)).coerceIn(0.1f, 1f)
}
?.takeIf { it > 0 }
?.let { loudnessDb ->
(1f - (0.01f + loudnessDb / 15)).coerceIn(0.1f, 1f)
}
} ?: 1f
}
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return Futures.immediateFuture(
mediaItems.map { mediaItem ->
mediaItem.buildUpon()
.setUri(mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaId)
.build()
}
)
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
@Player.DiscontinuityReason reason: Int
) {
stateBuilder
.setState(PlaybackStateCompat.STATE_NONE, newPosition.positionMs, 1f)
.setBufferedPosition(player.bufferedPosition)
updateNotification()
}
override fun createNotification(
session: MediaSession,
customLayout: ImmutableList<CommandButton>,
actionFactory: ActionFactory,
onNotificationChangedCallback: MediaNotification.Provider.Callback
): MediaNotification {
fun invalidate() {
onNotificationChangedCallback.onNotificationChanged(
createNotification(
session,
customLayout,
actionFactory,
onNotificationChangedCallback
)
override fun onIsPlayingChanged(isPlaying: Boolean) {
stateBuilder
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
player.currentPosition,
1f
)
}
.setBufferedPosition(player.bufferedPosition)
updateNotification()
}
private fun updateNotification() {
if (player.duration != C.TIME_UNSET) {
metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, player.duration)
mediaSession.setMetadata(metadataBuilder.build())
}
mediaSession.setPlaybackState(stateBuilder.build())
createNotification()
}
private fun createNotification() {
fun NotificationCompat.Builder.addMediaAction(
@DrawableRes resId: Int,
@StringRes stringId: Int,
@Player.Command command: Int
description: String,
@PlaybackStateCompat.MediaKeyAction command: Long
): NotificationCompat.Builder {
return addAction(
actionFactory.createMediaAction(
mediaSession,
IconCompat.createWithResource(this@PlayerService, resId),
getString(stringId),
command
NotificationCompat.Action(
resId,
description,
MediaButtonReceiver.buildMediaButtonPendingIntent(this@PlayerService, command)
)
)
}
val mediaMetadata = mediaSession.player.mediaMetadata
val mediaMetadata = player.mediaMetadata
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
.setContentTitle(mediaMetadata.title)
@@ -375,72 +238,75 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
.setShowWhen(false)
.setSmallIcon(R.drawable.app_icon)
.setOngoing(false)
.setContentIntent(mediaSession.sessionActivity)
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(
mediaSession,
Player.COMMAND_STOP.toLong()
)
)
.setContentIntent(activityPendingIntent<MainActivity>())
.setDeleteIntent(broadCastPendingIntent<StopServiceBroadcastReceiver>())
.setChannelId(NotificationChannelId)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token)
.setMediaSession(mediaSession.sessionToken)
)
.addMediaAction(
R.drawable.play_skip_back,
R.string.media3_controls_seek_to_previous_description,
Player.COMMAND_SEEK_TO_PREVIOUS
"Skip back",
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
).addMediaAction(
if (mediaSession.player.playbackState == Player.STATE_ENDED || !mediaSession.player.playWhenReady) R.drawable.play else R.drawable.pause,
if (mediaSession.player.playbackState == Player.STATE_ENDED || !mediaSession.player.playWhenReady) R.string.media3_controls_play_description else R.string.media3_controls_pause_description,
Player.COMMAND_PLAY_PAUSE
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) R.drawable.play else R.drawable.pause,
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) "Play" else "Pause",
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) PlaybackStateCompat.ACTION_PLAY else PlaybackStateCompat.ACTION_PAUSE
)
.addMediaAction(
R.drawable.play_skip_forward,
R.string.media3_controls_seek_to_next_description,
Player.COMMAND_SEEK_TO_NEXT
"Skip forward",
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
if (lastArtworkUri != mediaMetadata.artworkUri) {
coroutineScope.launch(Dispatchers.IO) {
lastBitmap = Coil.imageLoader(applicationContext).execute(
ImageRequest.Builder(applicationContext)
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
.build()
).drawable?.let {
lastArtworkUri = mediaMetadata.artworkUri
(it as BitmapDrawable).bitmap
} ?: resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
lastArtworkUri = mediaMetadata.artworkUri
withContext(Dispatchers.Main) {
invalidate()
}
}
Coil.imageLoader(applicationContext).enqueue(
ImageRequest.Builder(applicationContext)
.data(mediaMetadata.artworkUri.thumbnail(notificationThumbnailSize))
.listener(
onError = { _, _ ->
lastBitmap = resources.getDrawable(R.drawable.disc_placeholder, null)
?.toBitmap(notificationThumbnailSize, notificationThumbnailSize)
notificationManager.notify(NotificationId, builder.setLargeIcon(lastBitmap).build())
},
onSuccess = { _, result ->
lastBitmap = (result.drawable as BitmapDrawable).bitmap
notificationManager.notify(NotificationId, builder.setLargeIcon(lastBitmap).build())
}
)
.build()
)
}
return MediaNotification(NotificationId, builder.build())
}
val notificationCompat = builder.build()
startForeground(NotificationId, notificationCompat)
override fun handleCustomCommand(
session: MediaSession,
action: String,
extras: Bundle
): Boolean = false
if (player.playbackState == Player.STATE_ENDED || !player.playWhenReady) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(false)
}
notificationManager.notify(NotificationId, notificationCompat)
}
}
private fun createNotificationChannel() {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Util.SDK_INT < 26) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
with(notificationManager) {
if (getNotificationChannel(NotificationChannelId) == null) {
createNotificationChannel(
NotificationChannel(
NotificationChannelId,
getString(R.string.default_notification_channel_name),
"Now playing",
NotificationManager.IMPORTANCE_LOW
)
)
@@ -504,10 +370,12 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
if (mediaItem?.mediaId == videoId) {
Database.internal.queryExecutor.execute {
Database.update(Database.insert(mediaItem).copy(
loudnessDb = loudnessDb,
contentLength = format.contentLength
))
Database.update(
Database.insert(mediaItem).copy(
loudnessDb = loudnessDb,
contentLength = format.contentLength
)
)
}
}
@@ -553,14 +421,99 @@ class PlayerService : MediaSessionService(), MediaSession.Callback, MediaNotific
}
}
private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder {
return setSessionActivity(
PendingIntent.getActivity(
this@PlayerService,
0,
Intent(this@PlayerService, MainActivity::class.java),
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
inner class Binder : android.os.Binder() {
val player: ExoPlayer
get() = this@PlayerService.player
val cache: Cache
get() = this@PlayerService.cache
val sleepTimerMillisLeft: StateFlow<Long?>?
get() = timerJob?.millisLeft
fun startSleepTimer(delayMillis: Long) {
timerJob?.cancel()
timerJob = coroutineScope.timer(delayMillis) {
val notification = NotificationCompat
.Builder(this@PlayerService, SleepTimerNotificationChannelId)
.setContentTitle("Sleep timer ended")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setSmallIcon(R.drawable.app_icon)
.build()
notificationManager.notify(SleepTimerNotificationId, notification)
exitProcess(0)
}
}
fun cancelSleepTimer() {
timerJob?.cancel()
timerJob = null
}
fun startRadio(
endpoint: NavigationEndpoint.Endpoint.Watch?,
) {
startRadio(
videoId = endpoint?.videoId,
playlistId = endpoint?.playlistId,
playlistSetVideoId = endpoint?.playlistSetVideoId,
parameters = endpoint?.params,
justAdd = false
)
)
}
fun startRadio(
videoId: String?,
playlistId: String? = null,
playlistSetVideoId: String? = null,
parameters: String? = null,
justAdd: Boolean = true
) {
radio = null
YoutubePlayer.Radio(
videoId, playlistId, playlistSetVideoId, parameters
).let {
coroutineScope.launch(Dispatchers.Main) {
if (justAdd) {
player.addMediaItems(it.process().drop(1))
} else {
player.forcePlayFromBeginning(it.process())
}
radio = it
}
}
}
fun stopRadio() {
radio = null
}
}
private class SessionCallback(private val player: Player) : MediaSessionCompat.Callback() {
override fun onPlay() = player.play()
override fun onPause() = player.pause()
override fun onSkipToPrevious() = player.seekToPrevious()
override fun onSkipToNext() = player.seekToNext()
override fun onSeekTo(pos: Long) = player.seekTo(pos)
}
class StopServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
context.stopService(context.intent<PlayerService>())
}
}
companion object {
private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id"
private const val SleepTimerNotificationId = 1002
private const val SleepTimerNotificationChannelId = "sleep_timer_channel_id"
}
}