diff --git a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackCallback.kt b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackCallback.kt index e2a0465a..b10dfb5f 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackCallback.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackCallback.kt @@ -5,9 +5,12 @@ import androidx.media3.common.Player import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand 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 -class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Callback { +class VideoPlaybackCallback : MediaSession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { try { return MediaSession.ConnectionResult.AcceptedResultBuilder(session) @@ -18,8 +21,8 @@ class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Cal .build() ).setAvailableSessionCommands( MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() - .add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_FORWARD, Bundle.EMPTY)) - .add(SessionCommand(VideoPlaybackService.COMMAND_SEEK_BACKWARD, Bundle.EMPTY)) + .add(SessionCommand(COMMAND.SEEK_FORWARD.stringValue, Bundle.EMPTY)) + .add(SessionCommand(COMMAND.SEEK_BACKWARD.stringValue, Bundle.EMPTY)) .build() ) .build() @@ -34,10 +37,7 @@ class VideoPlaybackCallback(private val seekIntervalMS: Long) : MediaSession.Cal customCommand: SessionCommand, args: Bundle ): ListenableFuture { - when (customCommand.customAction) { - VideoPlaybackService.COMMAND_SEEK_FORWARD -> session.player.seekTo(session.player.contentPosition + seekIntervalMS) - VideoPlaybackService.COMMAND_SEEK_BACKWARD -> session.player.seekTo(session.player.contentPosition - seekIntervalMS) - } + handleCommand(commandFromString(customCommand.customAction), session) return super.onCustomCommand(session, controller, customCommand, args) } } diff --git a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt index 93da8905..ba2590e4 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt +++ b/android/src/main/java/com/brentvatne/exoplayer/VideoPlaybackService.kt @@ -2,6 +2,7 @@ package com.brentvatne.exoplayer import android.annotation.SuppressLint import android.app.Activity +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -18,6 +19,7 @@ import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaStyleNotificationHelper import androidx.media3.session.SessionCommand +import com.brentvatne.common.toolbox.DebugLog import okhttp3.internal.immutableListOf class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder() @@ -27,9 +29,9 @@ class VideoPlaybackService : MediaSessionService() { private var binder = PlaybackServiceBinder(this) private var sourceActivity: Class? = null - // Controls - private val commandSeekForward = SessionCommand(COMMAND_SEEK_FORWARD, Bundle.EMPTY) - private val commandSeekBackward = SessionCommand(COMMAND_SEEK_BACKWARD, Bundle.EMPTY) + // Controls for Android 13+ - see buildNotification function + private val commandSeekForward = SessionCommand(COMMAND.SEEK_FORWARD.stringValue, Bundle.EMPTY) + private val commandSeekBackward = SessionCommand(COMMAND.SEEK_BACKWARD.stringValue, Bundle.EMPTY) @SuppressLint("PrivateResource") private val seekForwardBtn = CommandButton.Builder() @@ -55,7 +57,7 @@ class VideoPlaybackService : MediaSessionService() { val mediaSession = MediaSession.Builder(this, player) .setId("RNVideoPlaybackService_" + player.hashCode()) - .setCallback(VideoPlaybackCallback(SEEK_INTERVAL_MS)) + .setCallback(VideoPlaybackCallback()) .setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn)) .build() @@ -115,16 +117,90 @@ class VideoPlaybackService : MediaSessionService() { return } + val notification = buildNotification(session) + + notificationManager.notify(session.player.hashCode(), notification) + } + + private fun buildNotification(session: MediaSession): Notification { val returnToPlayer = Intent(this, sourceActivity).apply { 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) { @@ -148,10 +224,63 @@ class VideoPlaybackService : MediaSessionService() { 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 { - const val COMMAND_SEEK_FORWARD = "SEEK_FORWARD" - const val COMMAND_SEEK_BACKWARD = "SEEK_BACKWARD" + private const val SEEK_INTERVAL_MS = 10000L + private const val TAG = "VideoPlaybackService" + 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?") + } + } } }