2024-05-07 12:30:57 +02:00
|
|
|
package com.brentvatne.exoplayer
|
|
|
|
|
|
|
|
import android.annotation.SuppressLint
|
2024-05-28 02:23:56 -05:00
|
|
|
import android.app.Activity
|
2024-06-12 11:54:45 +02:00
|
|
|
import android.app.Notification
|
2024-05-07 12:30:57 +02:00
|
|
|
import android.app.NotificationChannel
|
|
|
|
import android.app.NotificationManager
|
2024-05-28 02:23:56 -05:00
|
|
|
import android.app.PendingIntent
|
2024-05-07 12:30:57 +02:00
|
|
|
import android.content.Context
|
|
|
|
import android.content.Intent
|
|
|
|
import android.os.Binder
|
|
|
|
import android.os.Build
|
|
|
|
import android.os.Bundle
|
|
|
|
import android.os.IBinder
|
|
|
|
import androidx.core.app.NotificationCompat
|
|
|
|
import androidx.media3.exoplayer.ExoPlayer
|
|
|
|
import androidx.media3.session.CommandButton
|
|
|
|
import androidx.media3.session.MediaSession
|
|
|
|
import androidx.media3.session.MediaSessionService
|
|
|
|
import androidx.media3.session.MediaStyleNotificationHelper
|
|
|
|
import androidx.media3.session.SessionCommand
|
2024-06-12 11:54:45 +02:00
|
|
|
import com.brentvatne.common.toolbox.DebugLog
|
2024-05-07 12:30:57 +02:00
|
|
|
import okhttp3.internal.immutableListOf
|
|
|
|
|
|
|
|
class PlaybackServiceBinder(val service: VideoPlaybackService) : Binder()
|
|
|
|
|
|
|
|
class VideoPlaybackService : MediaSessionService() {
|
|
|
|
private var mediaSessionsList = mutableMapOf<ExoPlayer, MediaSession>()
|
|
|
|
private var binder = PlaybackServiceBinder(this)
|
2024-05-28 02:23:56 -05:00
|
|
|
private var sourceActivity: Class<Activity>? = null
|
2024-05-07 12:30:57 +02:00
|
|
|
|
2024-06-12 11:54:45 +02:00
|
|
|
// 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)
|
2024-05-07 12:30:57 +02:00
|
|
|
|
|
|
|
@SuppressLint("PrivateResource")
|
|
|
|
private val seekForwardBtn = CommandButton.Builder()
|
|
|
|
.setDisplayName("forward")
|
|
|
|
.setSessionCommand(commandSeekForward)
|
|
|
|
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_fastforward)
|
|
|
|
.build()
|
|
|
|
|
|
|
|
@SuppressLint("PrivateResource")
|
|
|
|
private val seekBackwardBtn = CommandButton.Builder()
|
|
|
|
.setDisplayName("backward")
|
|
|
|
.setSessionCommand(commandSeekBackward)
|
|
|
|
.setIconResId(androidx.media3.ui.R.drawable.exo_notification_rewind)
|
|
|
|
.build()
|
|
|
|
|
|
|
|
// Player Registry
|
|
|
|
|
2024-05-28 02:23:56 -05:00
|
|
|
fun registerPlayer(player: ExoPlayer, from: Class<Activity>) {
|
2024-05-07 12:30:57 +02:00
|
|
|
if (mediaSessionsList.containsKey(player)) {
|
|
|
|
return
|
|
|
|
}
|
2024-05-28 02:23:56 -05:00
|
|
|
sourceActivity = from
|
2024-05-07 12:30:57 +02:00
|
|
|
|
|
|
|
val mediaSession = MediaSession.Builder(this, player)
|
|
|
|
.setId("RNVideoPlaybackService_" + player.hashCode())
|
2024-06-12 11:54:45 +02:00
|
|
|
.setCallback(VideoPlaybackCallback())
|
2024-05-07 12:30:57 +02:00
|
|
|
.setCustomLayout(immutableListOf(seekBackwardBtn, seekForwardBtn))
|
|
|
|
.build()
|
|
|
|
|
|
|
|
mediaSessionsList[player] = mediaSession
|
|
|
|
addSession(mediaSession)
|
|
|
|
}
|
|
|
|
|
|
|
|
fun unregisterPlayer(player: ExoPlayer) {
|
|
|
|
hidePlayerNotification(player)
|
|
|
|
val session = mediaSessionsList.remove(player)
|
|
|
|
session?.release()
|
|
|
|
if (mediaSessionsList.isEmpty()) {
|
|
|
|
cleanup()
|
|
|
|
stopSelf()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Callbacks
|
|
|
|
|
2024-05-11 18:57:59 +02:00
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = null
|
2024-05-07 12:30:57 +02:00
|
|
|
|
|
|
|
override fun onBind(intent: Intent?): IBinder {
|
|
|
|
super.onBind(intent)
|
|
|
|
return binder
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
|
|
|
createSessionNotification(session)
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
|
|
cleanup()
|
|
|
|
stopSelf()
|
|
|
|
}
|
|
|
|
|
|
|
|
override fun onDestroy() {
|
|
|
|
cleanup()
|
|
|
|
super.onDestroy()
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun createSessionNotification(session: MediaSession) {
|
|
|
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
notificationManager.createNotificationChannel(
|
|
|
|
NotificationChannel(
|
|
|
|
NOTIFICATION_CHANEL_ID,
|
|
|
|
NOTIFICATION_CHANEL_ID,
|
|
|
|
NotificationManager.IMPORTANCE_LOW
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (session.player.currentMediaItem == null) {
|
|
|
|
notificationManager.cancel(session.player.hashCode())
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-12 11:54:45 +02:00
|
|
|
val notification = buildNotification(session)
|
|
|
|
|
|
|
|
notificationManager.notify(session.player.hashCode(), notification)
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun buildNotification(session: MediaSession): Notification {
|
2024-05-28 02:23:56 -05:00
|
|
|
val returnToPlayer = Intent(this, sourceActivity).apply {
|
|
|
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
|
|
}
|
2024-05-07 12:30:57 +02:00
|
|
|
|
2024-06-12 11:54:45 +02:00
|
|
|
/*
|
|
|
|
* 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()
|
|
|
|
}
|
2024-05-07 12:30:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private fun hidePlayerNotification(player: ExoPlayer) {
|
|
|
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
notificationManager.cancel(player.hashCode())
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun hideAllNotifications() {
|
|
|
|
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
notificationManager.cancelAll()
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun cleanup() {
|
|
|
|
hideAllNotifications()
|
|
|
|
mediaSessionsList.forEach { (_, session) ->
|
|
|
|
session.release()
|
|
|
|
}
|
|
|
|
mediaSessionsList.clear()
|
|
|
|
}
|
|
|
|
|
2024-06-12 11:54:45 +02:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2024-05-07 12:30:57 +02:00
|
|
|
companion object {
|
2024-06-12 11:54:45 +02:00
|
|
|
private const val SEEK_INTERVAL_MS = 10000L
|
|
|
|
private const val TAG = "VideoPlaybackService"
|
|
|
|
|
2024-05-07 12:30:57 +02:00
|
|
|
const val NOTIFICATION_CHANEL_ID = "RNVIDEO_SESSION_NOTIFICATION"
|
2024-06-12 11:54:45 +02:00
|
|
|
|
|
|
|
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?")
|
|
|
|
}
|
|
|
|
}
|
2024-05-07 12:30:57 +02:00
|
|
|
}
|
|
|
|
}
|