fix(android): show controls in notification on older androids (#3886)
This commit is contained in:
parent
2d793dbde1
commit
098a754110
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
|
/*
|
||||||
|
* 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)
|
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
|
||||||
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
||||||
.setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
.setContentIntent(PendingIntent.getActivity(this, 0, returnToPlayer, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||||
.build()
|
.build()
|
||||||
|
} else {
|
||||||
|
val playerId = session.player.hashCode()
|
||||||
|
|
||||||
notificationManager.notify(session.player.hashCode(), notificationCompact)
|
// 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?")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user