diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt index 9d6d869..8f149f8 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt @@ -4,17 +4,23 @@ import android.app.* import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.Bitmap import android.net.Uri import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder import android.support.v4.media.RatingCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat import androidx.annotation.MainThread import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.PRIORITY_LOW +import androidx.core.app.NotificationManagerCompat +import androidx.media.session.MediaButtonReceiver import com.doublesymmetry.kotlinaudio.models.* import com.doublesymmetry.kotlinaudio.models.NotificationButton.* +import com.doublesymmetry.kotlinaudio.notification.NotificationManager as KotlinAudioNotificationManager import com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer import com.doublesymmetry.trackplayer.R as TrackPlayerR import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toMilliseconds @@ -94,11 +100,52 @@ class MusicService : HeadlessJsTaskService() { private var compactCapabilities: List = emptyList() override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (handleNotificationAction(intent)) { + return START_STICKY + } startTask(getTaskConfig(intent)) startAndStopEmptyNotificationToAvoidANR() return START_STICKY } + private fun handleNotificationAction(intent: Intent?): Boolean { + val action = intent?.action ?: return false + if (!this::player.isInitialized) { + Timber.w("Ignoring notification action before player initialization: %s", action) + return true + } + when (action) { + ACTION_NOTIFICATION_PLAY -> play() + ACTION_NOTIFICATION_PAUSE -> pause() + ACTION_NOTIFICATION_NEXT -> skipToNext() + ACTION_NOTIFICATION_PREVIOUS -> { + dispatchPlaybackButtonEvent(MusicEvents.BUTTON_SKIP_PREVIOUS) + } + else -> return false + } + Timber.d("Handled explicit notification action: %s", action) + return true + } + + private fun dispatchPlaybackButtonEvent(event: String, data: Bundle? = null) { + val reactInstanceManager = reactNativeHost.reactInstanceManager + if (reactInstanceManager.currentReactContext != null) { + emit(event, data) + return + } + startTask(getTaskConfig(null)) + scope.launch { + repeat(40) { + if (reactInstanceManager.currentReactContext != null) { + emit(event, data) + return@launch + } + delay(100) + } + Timber.w("Timed out waiting for React context before dispatching event: %s", event) + } + } + /** * Workaround for the "Context.startForegroundService() did not then call Service.startForeground()" * within 5s" ANR and crash by creating an empty notification and stopping it right after. For more @@ -236,6 +283,11 @@ class MusicService : HeadlessJsTaskService() { val notificationConfig = NotificationConfig(buttonsList, accentColor, smallIcon, pendingIntent) player.notificationManager.createNotification(notificationConfig) + scope.launch { + delay(500) + player.notificationManager.createNotification(notificationConfig) + player.notificationManager.invalidate() + } // setup progress update events if configured progressUpdateJob?.cancel() @@ -279,6 +331,138 @@ class MusicService : HeadlessJsTaskService() { } } + private fun hasNotificationCapability(capability: Capability): Boolean { + return notificationCapabilities.contains(capability) || capabilities.contains(capability) + } + + private fun getMediaSessionCompat(): MediaSessionCompat? { + return try { + val field = KotlinAudioNotificationManager::class.java.getDeclaredField("mediaSession") + field.isAccessible = true + field.get(player.notificationManager) as? MediaSessionCompat + } catch (error: Exception) { + Timber.w(error, "Failed to access media session from notification manager") + null + } + } + + private fun buildTransportControlNotification(original: Notification): Notification { + val builder = NotificationCompat.Builder(this, getNotificationChannelId(original)) + .setContentTitle(original.extras?.getCharSequence(Notification.EXTRA_TITLE)) + .setContentText(original.extras?.getCharSequence(Notification.EXTRA_TEXT)) + .setSubText(original.extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) + .setContentIntent(original.contentIntent) + .setDeleteIntent(original.deleteIntent) + .setCategory(original.category ?: Notification.CATEGORY_TRANSPORT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setOngoing(player.playWhenReady) + .setShowWhen(original.`when` > 0) + .setWhen(original.`when`) + .setSmallIcon(ExoPlayerR.drawable.exo_notification_small_icon) + val mediaSession = getMediaSessionCompat() + val compactActionIndexes = mutableListOf() + + getNotificationLargeIcon(original)?.let { builder.setLargeIcon(it) } + if (original.color != 0) { + builder.color = original.color + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE + } + + if (hasNotificationCapability(Capability.SKIP_TO_PREVIOUS)) { + val previousIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_PREVIOUS, 1) + if (previousIntent != null) { + builder.addAction( + ExoPlayerR.drawable.exo_notification_previous, + "Previous", + previousIntent + ) + compactActionIndexes += compactActionIndexes.size + } + } + + if (hasNotificationCapability(Capability.PLAY) || hasNotificationCapability(Capability.PAUSE)) { + val playPauseIntent = if (player.isPlaying) { + createServiceActionPendingIntent(ACTION_NOTIFICATION_PAUSE, 2) + } else { + createServiceActionPendingIntent(ACTION_NOTIFICATION_PLAY, 2) + } + if (playPauseIntent != null) { + builder.addAction( + if (player.isPlaying) ExoPlayerR.drawable.exo_notification_pause + else ExoPlayerR.drawable.exo_notification_play, + if (player.isPlaying) "Pause" else "Play", + playPauseIntent + ) + compactActionIndexes += compactActionIndexes.size + } + } + + if (hasNotificationCapability(Capability.SKIP_TO_NEXT)) { + val nextIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_NEXT, 3) + if (nextIntent != null) { + builder.addAction( + ExoPlayerR.drawable.exo_notification_next, + "Next", + nextIntent + ) + compactActionIndexes += compactActionIndexes.size + } + } + + val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() + mediaSession?.sessionToken?.let { mediaStyle.setMediaSession(it) } + if (compactActionIndexes.isNotEmpty()) { + mediaStyle.setShowActionsInCompactView(*compactActionIndexes.toIntArray()) + } + + builder.setStyle(mediaStyle) + return builder.build() + } + + private fun createServiceActionPendingIntent(action: String, requestCode: Int): PendingIntent? { + val intent = Intent(this, MusicService::class.java).apply { + this.action = action + `package` = packageName + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getService(this, requestCode, intent, flags) + } + + private fun getNotificationChannelId(original: Notification): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + original.channelId ?: getString(TrackPlayerR.string.rntp_temporary_channel_id) + } else { + getString(TrackPlayerR.string.rntp_temporary_channel_id) + } + } + + private fun getNotificationLargeIcon(original: Notification): Bitmap? { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + original.getLargeIcon()?.loadDrawable(this)?.let { drawable -> + val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1 + val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1 + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap -> + val canvas = android.graphics.Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + } + } + } + else -> { + @Suppress("DEPRECATION") + original.largeIcon + } + } + } + private fun isCompact(capability: Capability): Boolean { return compactCapabilities.contains(capability) } @@ -573,7 +757,8 @@ class MusicService : HeadlessJsTaskService() { is NotificationState.POSTED -> { Timber.d("notification posted with id=%s, ongoing=%s", it.notificationId, it.ongoing) notificationId = it.notificationId; - notification = it.notification; + notification = buildTransportControlNotification(it.notification) + NotificationManagerCompat.from(this@MusicService).notify(notificationId!!, notification!!) if (it.ongoing) { if (player.playWhenReady) { startForegroundIfNecessary() @@ -850,5 +1035,10 @@ class MusicService : HeadlessJsTaskService() { const val DEFAULT_JUMP_INTERVAL = 15.0 const val DEFAULT_STOP_FOREGROUND_GRACE_PERIOD = 5 + + private const val ACTION_NOTIFICATION_PLAY = "com.doublesymmetry.trackplayer.notification.PLAY" + private const val ACTION_NOTIFICATION_PAUSE = "com.doublesymmetry.trackplayer.notification.PAUSE" + private const val ACTION_NOTIFICATION_NEXT = "com.doublesymmetry.trackplayer.notification.NEXT" + private const val ACTION_NOTIFICATION_PREVIOUS = "com.doublesymmetry.trackplayer.notification.PREVIOUS" } }