fix(android): show controls in notification on older androids (#3886)

This commit is contained in:
Krzysztof Moch 2024-06-12 11:54:45 +02:00 committed by GitHub
parent 2d793dbde1
commit 098a754110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 149 additions and 20 deletions

View File

@ -5,9 +5,12 @@ import androidx.media3.common.Player
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
import com.brentvatne.exoplayer.VideoPlaybackService.Companion.COMMAND
import com.brentvatne.exoplayer.VideoPlaybackService.Companion.commandFromString
import com.brentvatne.exoplayer.VideoPlaybackService.Companion.handleCommand
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Callback { class VideoPlaybackCallback : MediaSession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
try { try {
return MediaSession.ConnectionResult.AcceptedResultBuilder(session) return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
@ -18,8 +21,8 @@ class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Cal
.build() .build()
).setAvailableSessionCommands( ).setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_FORWARD, Bundle.EMPTY)) .add(SessionCommand(COMMAND.SEEK_FORWARD.stringValue, Bundle.EMPTY))
.add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_BACKWARD, Bundle.EMPTY)) .add(SessionCommand(COMMAND.SEEK_BACKWARD.stringValue, Bundle.EMPTY))
.build() .build()
) )
.build() .build()
@ -34,10 +37,7 @@ class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Cal
customCommand: SessionCommand, customCommand: SessionCommand,
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
when (customCommand.customAction) { handleCommand(commandFromString(customCommand.customAction), session)
VideoPlaybackService.COMMAND_SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS)
VideoPlaybackService.COMMAND_SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - seekIntervalMS)
}
return super.onCustomCommand(session, controller, customCommand, args) return super.onCustomCommand(session, controller, customCommand, args)
} }
} }

View File

@ -2,6 +2,7 @@ package com.brentvatne.exoplayer
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
@ -18,6 +19,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper import androidx.media3.session.MediaStyleNotificationHelper
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import com.brentvatne.common.toolbox.DebugLog
import okhttp3.internal.immutableListOf import okhttp3.internal.immutableListOf
class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder() class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder()
@ -27,9 +29,9 @@ class VideoPlaybackService : MediaSessionService() {
private var binder = PlaybackServiceBinder(this) private var binder = PlaybackServiceBinder(this)
private var sourceActivity: Class<Activity>? = null private var sourceActivity: Class<Activity>? = null
// Controls // Controls for Android 13+ - see buildNotification function
private val commandSeekForward = SessionCommand(COMMAND_SEEK_FORWARD, Bundle.EMPTY) private val commandSeekForward = SessionCommand(COMMAND.SEEK_FORWARD.stringValue, Bundle.EMPTY)
private val commandSeekBackward = SessionCommand(COMMAND_SEEK_BACKWARD, Bundle.EMPTY) private val commandSeekBackward = SessionCommand(COMMAND.SEEK_BACKWARD.stringValue, Bundle.EMPTY)
@SuppressLint("PrivateResource") @SuppressLint("PrivateResource")
private val seekForwardBtn = CommandButton.Builder() private val seekForwardBtn = CommandButton.Builder()
@ -55,7 +57,7 @@ class VideoPlaybackService : MediaSessionService() {
val mediaSession = MediaSession.Builder(this, player) val mediaSession = MediaSession.Builder(this, player)
.setId("RNVideoPlaybackService_" + player.hashCode()) .setId("RNVideoPlaybackService_" + player.hashCode())
.setCallback(VideoPlaybackCallback(SEEK_INTERVAL_MS)) .setCallback(VideoPlaybackCallback())
.setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn)) .setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn))
.build() .build()
@ -115,16 +117,90 @@ class VideoPlaybackService : MediaSessionService() {
return return
} }
val notification = buildNotification(session)
notificationManager.notify(session.player.hashCode(), notification)
}
private fun buildNotification(session: MediaSession): Notification {
val returnToPlayer = Intent(this, sourceActivity).apply { val returnToPlayer = Intent(this, sourceActivity).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val notificationCompact = NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID)
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.build()
notificationManager.notify(session.player.hashCode(), notificationCompact) /*
* On Android 13+ controls are automatically handled via media session
* On Android 12 and bellow we need to add controls manually
*/
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID)
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.build()
} else {
val playerId = session.player.hashCode()
// Action for COMMAND.SEEK_BACKWARD
val seekBackwardIntent = Intent(this, VideoPlaybackService::class.java).apply {
putExtra("PLAYER_ID", playerId)
putExtra("ACTION", COMMAND.SEEK_BACKWARD.stringValue)
}
val seekBackwardPendingIntent = PendingIntent.getService(
this,
playerId * 10,
seekBackwardIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// ACTION FOR COMMAND.TOGGLE_PLAY
val togglePlayIntent = Intent(this, VideoPlaybackService::class.java).apply {
putExtra("PLAYER_ID", playerId)
putExtra("ACTION", COMMAND.TOGGLE_PLAY.stringValue)
}
val togglePlayPendingIntent = PendingIntent.getService(
this,
playerId * 10 + 1,
togglePlayIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// ACTION FOR COMMAND.SEEK_FORWARD
val seekForwardIntent = Intent(this, VideoPlaybackService::class.java).apply {
putExtra("PLAYER_ID", playerId)
putExtra("ACTION", COMMAND.SEEK_FORWARD.stringValue)
}
val seekForwardPendingIntent = PendingIntent.getService(
this,
playerId * 10 + 2,
seekForwardIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
NotificationCompat.Builder(this, NOTIFICATION_CHANEL_ID)
// Show controls on lock screen even when user hides sensitive content.
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
// Add media control buttons that invoke intents in your media service
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_back, "Seek Backward", seekBackwardPendingIntent) // #0
.addAction(
if (session.player.isPlaying) {
androidx.media3.session.R.drawable.media3_notification_pause
} else {
androidx.media3.session.R.drawable.media3_notification_play
},
"Toggle Play",
togglePlayPendingIntent
) // #1
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_forward, "Seek Forward", seekForwardPendingIntent) // #2
// Apply the media style template
.setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2))
.setContentTitle(session.player.mediaMetadata.title)
.setContentText(session.player.mediaMetadata.description)
.setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setLargeIcon(session.player.mediaMetadata.artworkUri?.let { session.bitmapLoader.loadBitmap(it).get() })
.setOngoing(true)
.build()
}
} }
private fun hidePlayerNotification(player: ExoPlayer) { private fun hidePlayerNotification(player: ExoPlayer) {
@ -148,10 +224,63 @@ class VideoPlaybackService : MediaSessionService() {
mediaSessionsList.clear() mediaSessionsList.clear()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
val playerId = it.getIntExtra("PLAYER_ID", -1)
val actionCommand = it.getStringExtra("ACTION")
if (playerId < 0) {
DebugLog.w(TAG, "Received Command without playerId")
return super.onStartCommand(intent, flags, startId)
}
if (actionCommand == null) {
DebugLog.w(TAG, "Received Command without action command")
return super.onStartCommand(intent, flags, startId)
}
val session = mediaSessionsList.values.find { s -> s.player.hashCode() == playerId } ?: return super.onStartCommand(intent, flags, startId)
handleCommand(commandFromString(actionCommand), session)
}
return super.onStartCommand(intent, flags, startId)
}
companion object { companion object {
const val COMMAND_SEEK_FORWARD = "SEEK_FORWARD" private const val SEEK_INTERVAL_MS = 10000L
const val COMMAND_SEEK_BACKWARD = "SEEK_BACKWARD" private const val TAG = "VideoPlaybackService"
const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION" const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION"
const val SEEK_INTERVAL_MS = 10000L
enum class COMMAND(val stringValue: String) {
NONE("NONE"),
SEEK_FORWARD("COMMAND_SEEK_FORWARD"),
SEEK_BACKWARD("COMMAND_SEEK_BACKWARD"),
TOGGLE_PLAY("COMMAND_TOGGLE_PLAY"),
PLAY("COMMAND_PLAY"),
PAUSE("COMMAND_PAUSE")
}
fun commandFromString(value: String): COMMAND =
when (value) {
COMMAND.SEEK_FORWARD.stringValue -> COMMAND.SEEK_FORWARD
COMMAND.SEEK_BACKWARD.stringValue -> COMMAND.SEEK_BACKWARD
COMMAND.TOGGLE_PLAY.stringValue -> COMMAND.TOGGLE_PLAY
COMMAND.PLAY.stringValue -> COMMAND.PLAY
COMMAND.PAUSE.stringValue -> COMMAND.PAUSE
else -> COMMAND.NONE
}
fun handleCommand(command: COMMAND, session: MediaSession) {
// TODO: get somehow ControlsConfig here - for now hardcoded 10000ms
when (command) {
COMMAND.SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - SEEK_INTERVAL_MS)
COMMAND.SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + SEEK_INTERVAL_MS)
COMMAND.TOGGLE_PLAY -> handleCommand(if (session.player.isPlaying) COMMAND.PAUSE else COMMAND.PLAY, session)
COMMAND.PLAY -> session.player.play()
COMMAND.PAUSE -> session.player.pause()
else -> DebugLog.w(TAG, "Received COMMAND.NONE - was there an error?")
}
}
} }
} }