From 4ecb607f45699a69c6a12d78e5f4107dff2a52ed Mon Sep 17 00:00:00 2001 From: Antoine POPINEAU Date: Thu, 9 Jul 2020 23:01:35 +0200 Subject: [PATCH] Let the media session live when playback is paused. As per Android policy and internal logic, we stopped the playback foreground service when playback was paused. This made our PlayService elligible for garbage collection by the OS. This had the consequences of not allowing someone to pause playback and resume it after some time. Android would always kill the service after around one minute. This commit, on supported Android version (7.0+) detaches the notification when stopping the foreground service, leaving the notification in place even when the service is killed, allowing the user to resume playback whenever they please. We also had to move the MediaSession out of the service, for it to remain alive between service killing and resurrection. --- app/src/main/AndroidManifest.xml | 2 +- .../java/com/github/apognu/otter/Otter.kt | 22 ++ .../apognu/otter/activities/MainActivity.kt | 6 + .../otter/playback/MediaControlsManager.kt | 64 ++---- .../apognu/otter/playback/PlayerService.kt | 195 +++++++++++------- .../java/com/github/apognu/otter/utils/Bus.kt | 1 + 6 files changed, 165 insertions(+), 125 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bd3c747..602a0c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ - + when (command) { + is Command.StartService -> { + startService(Intent(this@MainActivity, PlayerService::class.java).apply { + putExtra(PlayerService.INITIAL_COMMAND_KEY, command.command.toString()) + }) + } + is Command.RefreshTrack -> refreshCurrentTrack(command.track) } } diff --git a/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt index 38a5bcc..53cc04f 100644 --- a/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt +++ b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt @@ -6,8 +6,6 @@ import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.media.MediaMetadata -import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -18,9 +16,7 @@ import com.github.apognu.otter.utils.* import com.squareup.picasso.Picasso import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default -import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking class MediaControlsManager(val context: Service, private val scope: CoroutineScope, private val mediaSession: MediaSessionCompat) { companion object { @@ -32,27 +28,6 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco private var notification: Notification? = null - fun buildTrackMetadata(track: Track?): MediaMetadataCompat { - track?.let { - val coverUrl = maybeNormalizeUrl(track.album.cover.original) - - return MediaMetadataCompat.Builder().apply { - putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title) - putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name) - putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000) - - try { - runBlocking(IO) { - this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get()) - } - } catch (e: Exception) { - } - }.build() - } - - return MediaMetadataCompat.Builder().build() - } - fun updateNotification(track: Track?, playing: Boolean) { if (notification == null && !playing) return @@ -82,7 +57,10 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco .setSmallIcon(R.drawable.ottershape) .run { coverUrl?.let { - try { setLargeIcon(Picasso.get().load(coverUrl).get()) } catch (_: Exception) {} + try { + setLargeIcon(Picasso.get().load(coverUrl).get()) + } catch (_: Exception) { + } return@run this } @@ -114,20 +92,16 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco .build() notification?.let { - NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it) + if (playing) { + context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it) + } else { + NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it) + } } - - if (playing) tick() } } } - fun tick() { - notification?.let { - context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it) - } - } - private fun action(icon: Int, title: String, id: Int): NotificationCompat.Action { val intent = Intent(context, MediaControlActionReceiver::class.java).apply { action = id.toString() } val pendingIntent = PendingIntent.getBroadcast(context, id, intent, 0) @@ -138,16 +112,16 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco class MediaControlActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> CommandBus.send( - Command.PreviousTrack - ) - MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> CommandBus.send( - Command.ToggleState - ) - MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> CommandBus.send( - Command.NextTrack - ) + val command = when (intent?.action) { + MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> Command.PreviousTrack + MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> Command.ToggleState + MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> Command.NextTrack + else -> null + } + + command?.let { + CommandBus.send(command) + CommandBus.send(Command.StartService(command)) } } } diff --git a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt index dacfd9f..c76e57c 100644 --- a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt +++ b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt @@ -8,28 +8,32 @@ import android.content.IntentFilter import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager +import android.media.MediaMetadata import android.os.Build import android.os.Bundle import android.os.IBinder import android.os.ResultReceiver -import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.PlaybackStateCompat import android.view.KeyEvent +import com.github.apognu.otter.Otter import com.github.apognu.otter.R import com.github.apognu.otter.utils.* import com.google.android.exoplayer2.* import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.source.TrackGroupArray import com.google.android.exoplayer2.trackselection.TrackSelectionArray -import kotlinx.coroutines.CoroutineScope +import com.squareup.picasso.Picasso +import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch class PlayerService : Service() { + companion object { + const val INITIAL_COMMAND_KEY = "start_command" + } + private var started = false private val scope: CoroutineScope = CoroutineScope(Job() + Main) @@ -40,9 +44,10 @@ class PlayerService : Service() { private lateinit var queue: QueueManager private lateinit var mediaControlsManager: MediaControlsManager - private lateinit var mediaSession: MediaSessionCompat private lateinit var player: SimpleExoPlayer + private val mediaMetadataBuilder = MediaMetadataCompat.Builder() + private lateinit var playerEventListener: PlayerEventListener private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver() @@ -51,7 +56,17 @@ class PlayerService : Service() { private lateinit var radioPlayer: RadioPlayer override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (!started) watchEventBus() + if (!started) { + watchEventBus() + + intent?.extras?.getString(INITIAL_COMMAND_KEY)?.let { + when (it) { + Command.ToggleState.toString() -> togglePlayback() + Command.NextTrack.toString() -> skipToNextTrack() + Command.PreviousTrack.toString() -> skipToPreviousTrack() + } + } + } started = true @@ -82,18 +97,7 @@ class PlayerService : Service() { } } - mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply { - isActive = true - setPlaybackState(PlaybackStateCompat.Builder() - .setActions( - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - ).build()) - } - - mediaControlsManager = MediaControlsManager(this, scope, mediaSession) + mediaControlsManager = MediaControlsManager(this, scope, Otter.get().mediaSession) player = SimpleExoPlayer.Builder(this).build().apply { playWhenReady = false @@ -102,40 +106,21 @@ class PlayerService : Service() { addListener(it) } - MediaSessionConnector(mediaSession).also { + MediaSessionConnector(Otter.get().mediaSession).also { it.setPlayer(this) + it.setQueueNavigator(OtterQueueNavigator()) it.setMediaMetadataProvider { - mediaControlsManager.buildTrackMetadata(queue.current()) + buildTrackMetadata(queue.current()) } - it.setQueueNavigator(object : MediaSessionConnector.QueueNavigator { - override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) {} - - override fun onCurrentWindowIndexChanged(player: Player) {} - - override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true - - override fun getSupportedQueueNavigatorActions(player: Player): Long { - return PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - } - - override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) {} - - override fun getActiveQueueItemId(player: Player?) = 0L - - override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) {} - - override fun onTimelineChanged(player: Player) {} - }) - it.setMediaButtonEventHandler { player, _, mediaButtonEvent -> mediaButtonEvent.extras?.getParcelable(Intent.EXTRA_KEY_EVENT)?.let { key -> if (key.action == KeyEvent.ACTION_UP) { when (key.keyCode) { - KeyEvent.KEYCODE_MEDIA_PLAY -> state(true) - KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false) + KeyEvent.KEYCODE_MEDIA_PLAY -> setPlaybackState(true) + KeyEvent.KEYCODE_MEDIA_PAUSE -> setPlaybackState(false) KeyEvent.KEYCODE_MEDIA_NEXT -> player.next() - KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> skipToPreviousTrack() } } } @@ -146,12 +131,12 @@ class PlayerService : Service() { } if (queue.current > -1) { - player.prepare(queue.datasources, true, true) + player.prepare(queue.datasources) Cache.get(this, "progress")?.let { progress -> player.seekTo(queue.current, progress.readLine().toLong()) - val (current, duration, percent) = progress(true) + val (current, duration, percent) = getProgress(true) ProgressBus.send(current, duration, percent) } @@ -179,7 +164,7 @@ class PlayerService : Service() { queue.replace(command.queue) player.prepare(queue.datasources, true, true) - state(true) + setPlaybackState(true) CommandBus.send(Command.RefreshTrack(queue.current())) } @@ -193,22 +178,17 @@ class PlayerService : Service() { queue.current = command.index player.seekTo(command.index, C.TIME_UNSET) - state(true) + setPlaybackState(true) CommandBus.send(Command.RefreshTrack(queue.current())) } - is Command.ToggleState -> toggle() - is Command.SetState -> state(command.state) + is Command.ToggleState -> togglePlayback() + is Command.SetState -> setPlaybackState(command.state) - is Command.NextTrack -> { - player.next() - - Cache.set(this@PlayerService, "progress", "0".toByteArray()) - ProgressBus.send(0, 0, 0) - } - is Command.PreviousTrack -> previousTrack() - is Command.Seek -> progress(command.progress) + is Command.NextTrack -> skipToNextTrack() + is Command.PreviousTrack -> skipToPreviousTrack() + is Command.Seek -> seek(command.progress) is Command.ClearQueue -> queue.clear() @@ -222,10 +202,6 @@ class PlayerService : Service() { is Command.PinTrack -> PinService.download(this@PlayerService, command.track) is Command.PinTracks -> command.tracks.forEach { PinService.download(this@PlayerService, it) } } - - if (player.playWhenReady) { - mediaControlsManager.tick() - } } } @@ -243,7 +219,7 @@ class PlayerService : Service() { while (true) { delay(1000) - val (current, duration, percent) = progress() + val (current, duration, percent) = getProgress() if (player.playWhenReady) { ProgressBus.send(current, duration, percent) @@ -256,6 +232,8 @@ class PlayerService : Service() { @SuppressLint("NewApi") override fun onDestroy() { + scope.cancel() + try { unregisterReceiver(headphonesUnpluggedReceiver) } catch (_: Exception) { @@ -272,11 +250,8 @@ class PlayerService : Service() { audioManager.abandonAudioFocus(audioFocusChangeListener) }) - mediaSession.isActive = false - mediaSession.release() - player.removeListener(playerEventListener) - state(false) + setPlaybackState(false) player.release() stopForeground(true) @@ -286,9 +261,9 @@ class PlayerService : Service() { } @SuppressLint("NewApi") - private fun state(state: Boolean) { + private fun setPlaybackState(state: Boolean) { if (!state) { - val (progress, _, _) = progress() + val (progress, _, _) = getProgress() Cache.set(this@PlayerService, "progress", progress.toString().toByteArray()) } @@ -329,11 +304,11 @@ class PlayerService : Service() { } } - private fun toggle() { - state(!player.playWhenReady) + private fun togglePlayback() { + setPlaybackState(!player.playWhenReady) } - private fun previousTrack() { + private fun skipToPreviousTrack() { if (player.currentPosition > 5000) { return player.seekTo(0) } @@ -341,7 +316,14 @@ class PlayerService : Service() { player.previous() } - private fun progress(force: Boolean = false): Triple { + private fun skipToNextTrack() { + player.next() + + Cache.set(this@PlayerService, "progress", "0".toByteArray()) + ProgressBus.send(0, 0, 0) + } + + private fun getProgress(force: Boolean = false): Triple { if (!player.playWhenReady && !force) return progressCache return queue.current()?.bestUpload()?.let { upload -> @@ -354,7 +336,7 @@ class PlayerService : Service() { } ?: Triple(0, 0, 0) } - private fun progress(value: Int) { + private fun seek(value: Int) { val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000 progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value) @@ -362,6 +344,28 @@ class PlayerService : Service() { player.seekTo(duration.toLong()) } + private fun buildTrackMetadata(track: Track?): MediaMetadataCompat { + track?.let { + val coverUrl = maybeNormalizeUrl(track.album.cover.original) + + return mediaMetadataBuilder.apply { + putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title) + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name) + putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000) + + try { + runBlocking(IO) { + this@apply.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, Picasso.get().load(coverUrl).get()) + } + } catch (e: Exception) { + } + }.build() + } + + return mediaMetadataBuilder.build() + } + + @SuppressLint("NewApi") inner class PlayerEventListener : Player.EventListener { override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { super.onPlayerStateChanged(playWhenReady, playbackState) @@ -388,7 +392,11 @@ class PlayerService : Service() { if (playbackState == Player.STATE_READY) { mediaControlsManager.updateNotification(queue.current(), false) - stopForeground(false) + + Build.VERSION_CODES.N.onApi( + { stopForeground(STOP_FOREGROUND_DETACH) }, + { stopForeground(false) } + ) } } } @@ -441,18 +449,18 @@ class PlayerService : Service() { AudioManager.AUDIOFOCUS_GAIN -> { player.volume = 1f - state(stateWhenLostFocus) + setPlaybackState(stateWhenLostFocus) stateWhenLostFocus = false } AudioManager.AUDIOFOCUS_LOSS -> { stateWhenLostFocus = false - state(false) + setPlaybackState(false) } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { stateWhenLostFocus = player.playWhenReady - state(false) + setPlaybackState(false) } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { @@ -462,4 +470,33 @@ class PlayerService : Service() { } } } -} + + inner class OtterQueueNavigator : MediaSessionConnector.QueueNavigator { + override fun onSkipToQueueItem(player: Player, controlDispatcher: ControlDispatcher, id: Long) { + CommandBus.send(Command.PlayTrack(id.toInt())) + } + + override fun onCurrentWindowIndexChanged(player: Player) {} + + override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?) = true + + override fun getSupportedQueueNavigatorActions(player: Player): Long { + return PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + } + + override fun onSkipToNext(player: Player, controlDispatcher: ControlDispatcher) { + skipToNextTrack() + } + + override fun getActiveQueueItemId(player: Player?) = queue.current.toLong() + + override fun onSkipToPrevious(player: Player, controlDispatcher: ControlDispatcher) { + skipToPreviousTrack() + } + + override fun onTimelineChanged(player: Player) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/Bus.kt b/app/src/main/java/com/github/apognu/otter/utils/Bus.kt index 606b08f..6cf2dd2 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Bus.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Bus.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.launch sealed class Command { + class StartService(val command: Command) : Command() object RefreshService : Command() object ToggleState : Command()