package com.github.libretube.util import android.annotation.SuppressLint import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat 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.core.app.NotificationCompat import androidx.core.os.bundleOf import coil.request.ImageRequest import com.github.libretube.R import com.github.libretube.api.obj.Streams import com.github.libretube.compat.PendingIntentCompat import com.github.libretube.constants.BACKGROUND_CHANNEL_ID import com.github.libretube.constants.IntentData import com.github.libretube.constants.PLAYER_NOTIFICATION_ID import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.PlayerHelper import com.github.libretube.ui.activities.MainActivity import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager.CustomActionReceiver class NowPlayingNotification( private val context: Context, private val player: ExoPlayer, private val isBackgroundPlayerNotification: Boolean ) { private var videoId: String? = null private var streams: Streams? = null private var bitmap: Bitmap? = null /** * The [MediaSessionCompat] for the [streams]. */ private lateinit var mediaSession: MediaSessionCompat /** * The [MediaSessionConnector] to connect with the [mediaSession] and implement it with the [player]. */ private lateinit var mediaSessionConnector: MediaSessionConnector /** * The [PlayerNotificationManager] to load the [mediaSession] content on it. */ private var playerNotification: PlayerNotificationManager? = null /** * The [descriptionAdapter] is used to show title, uploaderName and thumbnail of the video in the notification * Basic example [here](https://github.com/AnthonyMarkD/AudioPlayerSampleTest) */ private val descriptionAdapter = object : PlayerNotificationManager.MediaDescriptionAdapter { /** * sets the title of the notification */ override fun getCurrentContentTitle(player: Player): CharSequence { return streams?.title!! } /** * overrides the action when clicking the notification */ @SuppressLint("UnspecifiedImmutableFlag") override fun createCurrentContentIntent(player: Player): PendingIntent { // starts a new MainActivity Intent when the player notification is clicked // it doesn't start a completely new MainActivity because the MainActivity's launchMode // is set to "singleTop" in the AndroidManifest (important!!!) // that's the only way to launch back into the previous activity (e.g. the player view val intent = Intent(context, MainActivity::class.java).apply { if (isBackgroundPlayerNotification) { putExtra(IntentData.openAudioPlayer, true) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } return PendingIntentCompat.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT ) } /** * the description of the notification (below the title) */ override fun getCurrentContentText(player: Player): CharSequence? { return streams?.uploader } /** * return the icon/thumbnail of the video */ override fun getCurrentLargeIcon( player: Player, callback: PlayerNotificationManager.BitmapCallback ): Bitmap? { if (DataSaverMode.isEnabled(context)) return null if (bitmap == null) enqueueThumbnailRequest(callback) return bitmap } override fun getCurrentSubText(player: Player): CharSequence? { return streams?.uploader } } private fun enqueueThumbnailRequest(callback: PlayerNotificationManager.BitmapCallback) { val request = ImageRequest.Builder(context) .data(streams?.thumbnailUrl) .target { result -> val bm = (result as BitmapDrawable).bitmap // returns the bitmap on Android 13+, for everything below scaled down to a square bitmap = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { ImageHelper.getSquareBitmap(bm) } else { bm } callback.onBitmap(bitmap!!) } .build() // enqueue the thumbnail loading request ImageHelper.imageLoader.enqueue(request) } private val customActionReceiver = object : CustomActionReceiver { override fun createCustomActions( context: Context, instanceId: Int ): MutableMap { return mutableMapOf( PREV to createNotificationAction(R.drawable.ic_prev_outlined, PREV, instanceId), NEXT to createNotificationAction(R.drawable.ic_next_outlined, NEXT, instanceId), REWIND to createNotificationAction(R.drawable.ic_rewind_md, REWIND, instanceId), FORWARD to createNotificationAction(R.drawable.ic_forward_md, FORWARD, instanceId) ) } override fun getCustomActions(player: Player): MutableList { return mutableListOf(PREV, NEXT, REWIND, FORWARD) } override fun onCustomAction(player: Player, action: String, intent: Intent) { handlePlayerAction(action) } } private fun createNotificationAction(drawableRes: Int, actionName: String, instanceId: Int): NotificationCompat.Action { val intent = Intent(actionName).setPackage(context.packageName) val pendingIntent = PendingIntentCompat.getBroadcast( context, instanceId, intent, PendingIntent.FLAG_CANCEL_CURRENT ) return NotificationCompat.Action.Builder(drawableRes, actionName, pendingIntent).build() } private fun createMediaSessionAction(@DrawableRes drawableRes: Int, actionName: String): MediaSessionConnector.CustomActionProvider { return object : MediaSessionConnector.CustomActionProvider { override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes).build() } override fun onCustomAction(player: Player, action: String, extras: Bundle?) { handlePlayerAction(action) } } } /** * Creates a [MediaSessionCompat] amd a [MediaSessionConnector] for the player */ private fun createMediaSession() { if (this::mediaSession.isInitialized) return mediaSession = MediaSessionCompat(context, this.javaClass.name).apply { isActive = true } mediaSessionConnector = MediaSessionConnector(mediaSession).apply { setPlayer(player) setQueueNavigator(object : TimelineQueueNavigator(mediaSession) { override fun getMediaDescription( player: Player, windowIndex: Int ): MediaDescriptionCompat { val appIcon = BitmapFactory.decodeResource( context.resources, R.drawable.ic_launcher_monochrome ) val title = streams?.title!! val uploader = streams?.uploader val extras = bundleOf( MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon, MediaMetadataCompat.METADATA_KEY_TITLE to title, MediaMetadataCompat.METADATA_KEY_ARTIST to uploader ) return MediaDescriptionCompat.Builder() .setTitle(title) .setSubtitle(uploader) .setIconBitmap(appIcon) .setExtras(extras) .build() } override fun getSupportedQueueNavigatorActions(player: Player): Long { return PlaybackStateCompat.ACTION_PLAY_PAUSE } }) setCustomActionProviders( createMediaSessionAction(R.drawable.ic_prev_outlined, PREV), createMediaSessionAction(R.drawable.ic_next_outlined, NEXT), createMediaSessionAction(R.drawable.ic_rewind_md, REWIND), createMediaSessionAction(R.drawable.ic_forward_md, FORWARD) ) } } private fun handlePlayerAction(action: String) { when (action) { NEXT -> { if (PlayingQueue.hasNext()) { PlayingQueue.onQueueItemSelected( PlayingQueue.currentIndex() + 1 ) } } PREV -> { if (PlayingQueue.hasPrev()) { PlayingQueue.onQueueItemSelected( PlayingQueue.currentIndex() - 1 ) } } REWIND -> { player.seekTo(player.currentPosition - PlayerHelper.seekIncrement) } FORWARD -> { player.seekTo(player.currentPosition + PlayerHelper.seekIncrement) } } } /** * Updates or creates the [playerNotification] */ fun updatePlayerNotification( videoId: String, streams: Streams ) { this.videoId = videoId this.streams = streams // reset the thumbnail bitmap in order to become reloaded for the new video this.bitmap = null if (playerNotification == null) { createMediaSession() createNotification() } } /** * Initializes the [playerNotification] attached to the [player] and shows it. */ private fun createNotification() { playerNotification = PlayerNotificationManager .Builder(context, PLAYER_NOTIFICATION_ID, BACKGROUND_CHANNEL_ID) // set the description of the notification .setMediaDescriptionAdapter(descriptionAdapter) // register the receiver for custom actions, doesn't seem to change anything .setCustomActionReceiver(customActionReceiver) .build().apply { setPlayer(player) setColorized(true) setMediaSessionToken(mediaSession.sessionToken) setSmallIcon(R.drawable.ic_launcher_lockscreen) setUseNextAction(false) setUsePreviousAction(false) setUseStopAction(true) } } /** * Destroy the [NowPlayingNotification] */ fun destroySelfAndPlayer() { playerNotification?.setPlayer(null) mediaSession.isActive = false mediaSession.release() player.stop() player.release() val notificationManager = context.getSystemService( Context.NOTIFICATION_SERVICE ) as NotificationManager notificationManager.cancel(PLAYER_NOTIFICATION_ID) } companion object { private const val PREV = "prev" private const val NEXT = "next" private const val REWIND = "rewind" private const val FORWARD = "forward" } }