package com.github.libretube.util import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Bitmap import android.os.Build import android.os.Bundle 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.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.graphics.drawable.toBitmap import androidx.media.app.NotificationCompat.MediaStyle import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import coil.request.ImageRequest import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.R import com.github.libretube.constants.IntentData import com.github.libretube.enums.NotificationId import com.github.libretube.extensions.seekBy import com.github.libretube.extensions.toMediaMetadataCompat import com.github.libretube.extensions.togglePlayPauseState import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.PlayerHelper import com.github.libretube.obj.PlayerNotificationData import com.github.libretube.services.OfflinePlayerService import com.github.libretube.ui.activities.MainActivity import java.util.UUID @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) class NowPlayingNotification( private val context: Context, private val player: ExoPlayer, private val notificationType: NowPlayingNotificationType ) { private var videoId: String? = null private val nManager = context.getSystemService()!! /** * The metadata of the current playing song (thumbnail, title, uploader) */ private var notificationData: PlayerNotificationData? = null /** * The [MediaSessionCompat] for the [notificationData]. */ private lateinit var mediaSession: MediaSessionCompat /** * The [NotificationCompat.Builder] to load the [mediaSession] content on it. */ private var notificationBuilder: NotificationCompat.Builder? = null /** * The [Bitmap] which represents the background / thumbnail of the notification */ private var notificationBitmap: Bitmap? = null private fun loadCurrentLargeIcon() { if (DataSaverMode.isEnabled(context)) return if (notificationBitmap == null) { enqueueThumbnailRequest { createOrUpdateNotification() } } } private fun createCurrentContentIntent(): 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 (notificationType == NowPlayingNotificationType.AUDIO_ONLINE) { putExtra(IntentData.openAudioPlayer, true) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } } return PendingIntentCompat .getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false) } private fun createIntent(action: String): PendingIntent? { val intent = Intent(action) .setPackage(context.packageName) return PendingIntentCompat .getBroadcast(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT, false) } private fun enqueueThumbnailRequest(callback: (Bitmap) -> Unit) { // If playing a downloaded file, show the downloaded thumbnail instead of loading an // online image notificationData?.thumbnailPath?.let { path -> ImageHelper.getDownloadedImage(context, path)?.let { notificationBitmap = processBitmap(it) callback.invoke(notificationBitmap!!) } return } val request = ImageRequest.Builder(context) .data(notificationData?.thumbnailUrl) .target { notificationBitmap = processBitmap(it.toBitmap()) callback.invoke(notificationBitmap!!) } .build() // enqueue the thumbnail loading request ImageHelper.imageLoader.enqueue(request) } private fun processBitmap(bitmap: Bitmap): Bitmap { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { bitmap } else { ImageHelper.getSquareBitmap(bitmap) } } private val legacyNotificationButtons get() = listOf( createNotificationAction(R.drawable.ic_prev_outlined, PREV), createNotificationAction( if (player.isPlaying) R.drawable.ic_pause else R.drawable.ic_play, PLAY_PAUSE ), createNotificationAction(R.drawable.ic_next_outlined, NEXT), createNotificationAction(R.drawable.ic_rewind_md, REWIND), createNotificationAction(R.drawable.ic_forward_md, FORWARD) ) private fun createNotificationAction( drawableRes: Int, actionName: String ): NotificationCompat.Action { return NotificationCompat.Action.Builder(drawableRes, actionName, createIntent(actionName)) .build() } private fun createMediaSessionAction( @DrawableRes drawableRes: Int, actionName: String ): PlaybackStateCompat.CustomAction { return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes).build() } /** * Creates a [MediaSessionCompat] for the player */ private fun createMediaSession() { if (this::mediaSession.isInitialized) return val sessionCallback = object : MediaSessionCompat.Callback() { override fun onSkipToNext() { handlePlayerAction(NEXT) super.onSkipToNext() } override fun onSkipToPrevious() { handlePlayerAction(PREV) super.onSkipToPrevious() } override fun onRewind() { handlePlayerAction(REWIND) super.onRewind() } override fun onFastForward() { handlePlayerAction(FORWARD) super.onFastForward() } override fun onPlay() { handlePlayerAction(PLAY_PAUSE) super.onPlay() } override fun onPause() { handlePlayerAction(PLAY_PAUSE) super.onPause() } override fun onStop() { handlePlayerAction(STOP) super.onStop() } override fun onSeekTo(pos: Long) { player.seekTo(pos) super.onSeekTo(pos) } override fun onCustomAction(action: String, extras: Bundle?) { handlePlayerAction(action) super.onCustomAction(action, extras) } } mediaSession = MediaSessionCompat(context, UUID.randomUUID().toString()) mediaSession.setCallback(sessionCallback) updateSessionMetadata() updateSessionPlaybackState() val playerStateListener = object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) updateSessionPlaybackState(isPlaying = isPlaying) } override fun onIsLoadingChanged(isLoading: Boolean) { super.onIsLoadingChanged(isLoading) if (!isLoading) { updateSessionMetadata() } updateSessionPlaybackState(isLoading = isLoading) } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { super.onMediaMetadataChanged(mediaMetadata) updateSessionMetadata(mediaMetadata) } } player.addListener(playerStateListener) } private fun updateSessionMetadata(metadata: MediaMetadata? = null) { val data = metadata ?: player.mediaMetadata val newMetadata = data.toMediaMetadataCompat(player.duration, notificationBitmap) mediaSession.setMetadata(newMetadata) } private fun updateSessionPlaybackState(isPlaying: Boolean? = null, isLoading: Boolean? = null) { val loading = isLoading == true || (isPlaying == false && player.isLoading) val newPlaybackState = when { loading -> PlaybackStateCompat.STATE_BUFFERING isPlaying ?: player.isPlaying -> PlaybackStateCompat.STATE_PLAYING else -> PlaybackStateCompat.STATE_PAUSED } mediaSession.setPlaybackState(createPlaybackState(newPlaybackState)) } private fun createPlaybackState(@PlaybackStateCompat.State state: Int): PlaybackStateCompat { val stateActions = PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_FAST_FORWARD or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_SEEK_TO return PlaybackStateCompat.Builder() .setActions(stateActions) .addCustomAction(createMediaSessionAction(R.drawable.ic_rewind_md, REWIND)) .addCustomAction(createMediaSessionAction(R.drawable.ic_forward_md, FORWARD)) .setState(state, player.currentPosition, player.playbackParameters.speed) .build() } private fun handlePlayerAction(action: String) { when (action) { NEXT -> { PlayingQueue.navigateNext() } PREV -> { PlayingQueue.navigatePrev() } REWIND -> { player.seekBy(-PlayerHelper.seekIncrement) } FORWARD -> { player.seekBy(PlayerHelper.seekIncrement) } PLAY_PAUSE -> { player.togglePlayPauseState() } STOP -> { when (notificationType) { NowPlayingNotificationType.AUDIO_ONLINE -> BackgroundHelper.stopBackgroundPlay( context ) NowPlayingNotificationType.AUDIO_OFFLINE -> BackgroundHelper.stopBackgroundPlay( context, OfflinePlayerService::class.java ) else -> Unit } } } } /** * Updates or creates the [notificationBuilder] */ fun updatePlayerNotification(videoId: String, data: PlayerNotificationData) { this.videoId = videoId this.notificationData = data // reset the thumbnail bitmap in order to become reloaded for the new video this.notificationBitmap = null loadCurrentLargeIcon() if (notificationBuilder == null) { createMediaSession() createNotificationBuilder() createActionReceiver() // update the notification each time the player continues playing or pauses player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { createOrUpdateNotification() super.onIsPlayingChanged(isPlaying) } }) } createOrUpdateNotification() } /** * Initializes the [notificationBuilder] attached to the [player] and shows it. */ private fun createNotificationBuilder() { notificationBuilder = NotificationCompat.Builder(context, PLAYER_CHANNEL_NAME) .setSmallIcon(R.drawable.ic_launcher_lockscreen) .setContentIntent(createCurrentContentIntent()) .setDeleteIntent(createIntent(STOP)) .setStyle( MediaStyle() .setMediaSession(mediaSession.sessionToken) .setShowActionsInCompactView(1) ) } private fun createOrUpdateNotification() { if (notificationBuilder == null) return val notification = notificationBuilder!! .setContentTitle(notificationData?.title) .setContentText(notificationData?.uploaderName) .setLargeIcon(notificationBitmap) .clearActions() .apply { legacyNotificationButtons.forEach { addAction(it) } } .build() updateSessionMetadata() nManager.notify(NotificationId.PLAYER_PLAYBACK.id, notification) } private val notificationActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { handlePlayerAction(intent.action ?: return) } } private fun createActionReceiver() { val filter = IntentFilter().apply { listOf(PREV, NEXT, REWIND, FORWARD, PLAY_PAUSE, STOP).forEach { addAction(it) } } ContextCompat.registerReceiver( context, notificationActionReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED ) } /** * Destroy the [NowPlayingNotification] */ fun destroySelf() { mediaSession.release() runCatching { context.unregisterReceiver(notificationActionReceiver) } nManager.cancel(NotificationId.PLAYER_PLAYBACK.id) } fun cancelNotification() { nManager.cancel(NotificationId.PLAYER_PLAYBACK.id) } fun refreshNotification() { createOrUpdateNotification() } companion object { private const val PREV = "prev" private const val NEXT = "next" private const val REWIND = "rewind" private const val FORWARD = "forward" private const val PLAY_PAUSE = "play_pause" private const val STOP = "stop" enum class NowPlayingNotificationType { VIDEO_ONLINE, VIDEO_OFFLINE, AUDIO_ONLINE, AUDIO_OFFLINE } } }