playback: cleanup components

Fix miscellanious bugs and clean the component code.

Currently the components are in a strange state. They are a big ball of
mud with inconsistent lifecycles and callbacks. I want to find a way to
unify them under a single lifecycle, but the competing nature of them
makes this extremely difficult.
This commit is contained in:
OxygenCobalt 2022-04-29 19:53:58 -06:00
parent 6adc5f8715
commit d57f980148
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 107 additions and 135 deletions

View file

@ -23,38 +23,50 @@ import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.session.MediaButtonReceiver
import com.google.android.exoplayer2.Player
import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.logD
/**
* Nightmarish class that coordinates communication between [MediaSessionCompat], [Player], and
* [PlaybackStateManager].
*/
class PlaybackSessionConnector(
private val context: Context,
private val player: Player,
private val mediaSession: MediaSessionCompat
) : PlaybackStateManager.Callback, Player.Listener, MediaSessionCompat.Callback() {
class MediaSessionComponent(private val context: Context, private val player: Player) :
PlaybackStateManager.Callback,
Player.Listener,
SettingsManager.Callback,
MediaSessionCompat.Callback() {
private val playbackManager = PlaybackStateManager.getInstance()
private val emptyMetadata = MediaMetadataCompat.Builder().build()
private val settingsManager = SettingsManager.getInstance()
private val mediaSession = MediaSessionCompat(context, context.packageName)
val token: MediaSessionCompat.Token
get() = mediaSession.sessionToken
init {
mediaSession.setCallback(this)
playbackManager.addCallback(this)
settingsManager.addCallback(this)
player.addListener(this)
onSongChanged(playbackManager.song)
onPlayingChanged(playbackManager.isPlaying)
}
fun handleMediaButtonIntent(intent: Intent) {
MediaButtonReceiver.handleIntent(mediaSession, intent)
}
fun release() {
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
player.removeListener(this)
mediaSession.release()
}
// --- MEDIASESSION CALLBACKS ---
@ -111,10 +123,6 @@ class PlaybackSessionConnector(
onSongChanged(playbackManager.song)
}
override fun onQueueChanged(index: Int, queue: List<Song>) {
onSongChanged(playbackManager.song)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
onSongChanged(playbackManager.song)
}
@ -152,18 +160,42 @@ class PlaybackSessionConnector(
invalidateSessionState()
}
// -- EXOPLAYER CALLBACKS ---
override fun onRepeatChanged(repeatMode: RepeatMode) {
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.containsAny(
Player.EVENT_POSITION_DISCONTINUITY,
Player.EVENT_PLAYBACK_STATE_CHANGED,
Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_IS_PLAYING_CHANGED,
Player.EVENT_REPEAT_MODE_CHANGED,
Player.EVENT_PLAYBACK_PARAMETERS_CHANGED)) {
invalidateSessionState()
}
override fun onShuffledChanged(isShuffled: Boolean) {
mediaSession.setShuffleMode(
if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
}
// -- SETTINGSMANAGER CALLBACKS --
override fun onShowCoverUpdate(showCovers: Boolean) {
onSongChanged(playbackManager.song)
}
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
onSongChanged(playbackManager.song)
}
// -- EXOPLAYER CALLBACKS --
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
invalidateSessionState()
}
// --- MISC ---
@ -205,6 +237,8 @@ class PlaybackSessionConnector(
}
companion object {
private val emptyMetadata = MediaMetadataCompat.Builder().build()
const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or

View file

@ -27,24 +27,38 @@ import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.coil.loadBitmap
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.newBroadcastIntent
import org.oxycblt.auxio.util.newMainIntent
/**
* The unified notification for [PlaybackService]. This is not self-sufficient, updates have to be
* delivered manually.
* The unified notification for [PlaybackService]. Due to the nature of how this notification is
* used, it is *not self-sufficient*. Updates have to be delivered manually, as to prevent state
* inconsistency when the foreground state is started.
* @author OxygenCobalt
*/
@SuppressLint("RestrictedApi")
class PlaybackNotification
private constructor(private val context: Context, mediaToken: MediaSessionCompat.Token) :
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_channel_name),
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
setSmallIcon(R.drawable.ic_auxio)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setShowWhen(false)
@ -58,11 +72,11 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat
addAction(buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next))
addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_exit))
setStyle(MediaStyle().setMediaSession(mediaToken).setShowActionsInCompactView(1, 2, 3))
setStyle(MediaStyle().setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3))
}
// Don't connect to PlaybackStateManager here. This is because it's possible for this
// notification to not be updated by PlaybackStateManager before PlaybackService pushes
// the notification, resulting in invalid metadata.
fun renotify() {
notificationManager.notify(IntegerTable.NOTIFICATION_CODE, build())
}
// --- STATE FUNCTIONS ---
@ -71,13 +85,15 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat
* Set the metadata of the notification using [song].
* @param onDone What to do when the loading of the album art is finished
*/
fun setMetadata(song: Song, onDone: () -> Unit) {
fun updateMetadata(song: Song, parent: MusicParent?, onDone: () -> Unit) {
setContentTitle(song.resolveName(context))
setContentText(song.resolveIndividualArtistName(context))
// On older versions of android [API <24], show the song's album on the subtext instead of
// the current mode, as that makes more sense for the old style of media notifications.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// Starting in API 24, the subtext field changed semantics from being below the content
// text to being above the title.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setSubText(parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
} else {
setSubText(song.resolveName(context))
}
@ -90,28 +106,20 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat
}
/** Set the playing icon on the notification */
fun setPlaying(isPlaying: Boolean) {
fun updatePlaying(isPlaying: Boolean) {
mActions[2] = buildPlayPauseAction(context, isPlaying)
}
/** Update the first action to reflect the [repeatMode] given. */
fun setRepeatMode(repeatMode: RepeatMode) {
fun updateRepeatMode(repeatMode: RepeatMode) {
mActions[0] = buildRepeatAction(context, repeatMode)
}
/** Update the first action to reflect whether the queue is shuffled or not */
fun setShuffled(isShuffled: Boolean) {
fun updateShuffled(isShuffled: Boolean) {
mActions[0] = buildShuffleAction(context, isShuffled)
}
/** Apply the current [parent] to the header of the notification. */
fun setParent(parent: MusicParent?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
// A blank parent always means that the mode is ALL_SONGS
setSubText(parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
}
// --- NOTIFICATION ACTION BUILDERS ---
private fun buildPlayPauseAction(
@ -161,24 +169,5 @@ private constructor(private val context: Context, mediaToken: MediaSessionCompat
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK"
/** Build a new instance of [PlaybackNotification]. */
fun from(
context: Context,
notificationManager: NotificationManager,
mediaSession: MediaSessionCompat
): PlaybackNotification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.info_channel_name),
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
return PlaybackNotification(context, mediaSession.sessionToken)
}
}
}

View file

@ -17,19 +17,13 @@
package org.oxycblt.auxio.playback.system
import android.app.NotificationManager
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.media.AudioManager
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import androidx.media.session.MediaButtonReceiver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@ -56,7 +50,6 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetController
import org.oxycblt.auxio.widgets.WidgetProvider
@ -71,25 +64,16 @@ import org.oxycblt.auxio.widgets.WidgetProvider
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
* therefore there's no need to bind to it to deliver commands.
* @author OxygenCobalt
*
* TODO: Move all external exposal from passing around PlaybackStateManager to passing around the
* MediaMetadata instance. Generally makes it easier to encapsulate this class.
*
* TODO: Move hasPlayed to here as well.
*/
class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
// Player components
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSessionCompat
private lateinit var connector: PlaybackSessionConnector
private val replayGainProcessor = ReplayGainAudioProcessor()
// Notification components
private lateinit var notification: PlaybackNotification
private lateinit var notificationManager: NotificationManager
// System backend components
private lateinit var notificationComponent: NotificationComponent
private lateinit var mediaSessionComponent: MediaSessionComponent
private lateinit var widgets: WidgetController
private val systemReceiver = PlaybackReceiver()
@ -126,8 +110,8 @@ class PlaybackService :
// --- SYSTEM SETUP ---
widgets = WidgetController(this)
mediaSession = MediaSessionCompat(this, packageName).apply { isActive = true }
connector = PlaybackSessionConnector(this, player, mediaSession)
mediaSessionComponent = MediaSessionComponent(this, player)
notificationComponent = NotificationComponent(this, mediaSessionComponent.token)
// Then the notification/headset callbacks
IntentFilter().apply {
@ -145,11 +129,6 @@ class PlaybackService :
registerReceiver(systemReceiver, this)
}
// --- NOTIFICATION SETUP ---
notificationManager = getSystemServiceSafe(NotificationManager::class)
notification = PlaybackNotification.from(this, notificationManager, mediaSession)
// --- PLAYBACKSTATEMANAGER SETUP ---
playbackManager.addCallback(this)
@ -168,7 +147,7 @@ class PlaybackService :
if (intent.action == Intent.ACTION_MEDIA_BUTTON) {
// Workaround to get GadgetBridge and other apps that blindly query for
// ACTION_MEDIA_BUTTON working.
MediaButtonReceiver.handleIntent(mediaSession, intent)
mediaSessionComponent.handleMediaButtonIntent(intent)
}
return START_NOT_STICKY
@ -188,8 +167,7 @@ class PlaybackService :
serviceJob.cancel()
player.release()
connector.release()
mediaSession.release()
mediaSessionComponent.release()
widgets.release()
playbackManager.removeCallback(this)
@ -273,7 +251,8 @@ class PlaybackService :
logD("Setting player to ${song.rawName}")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
notification.setMetadata(song, ::startForegroundOrNotify)
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
return
}
@ -285,20 +264,20 @@ class PlaybackService :
override fun onPlayingChanged(isPlaying: Boolean) {
player.playWhenReady = isPlaying
notification.setPlaying(isPlaying)
notificationComponent.updatePlaying(isPlaying)
startForegroundOrNotify()
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
if (!settingsManager.useAltNotifAction) {
notification.setRepeatMode(repeatMode)
notificationComponent.updateRepeatMode(repeatMode)
startForegroundOrNotify()
}
}
override fun onShuffledChanged(isShuffled: Boolean) {
if (settingsManager.useAltNotifAction) {
notification.setShuffled(isShuffled)
notificationComponent.updateShuffled(isShuffled)
startForegroundOrNotify()
}
}
@ -309,18 +288,11 @@ class PlaybackService :
// --- SETTINGSMANAGER OVERRIDES ---
override fun onColorizeNotifUpdate(doColorize: Boolean) {
playbackManager.song?.let { song ->
connector.onSongChanged(song)
notification.setMetadata(song, ::startForegroundOrNotify)
}
}
override fun onNotifActionUpdate(useAltAction: Boolean) {
if (useAltAction) {
notification.setShuffled(playbackManager.isShuffled)
notificationComponent.updateShuffled(playbackManager.isShuffled)
} else {
notification.setRepeatMode(playbackManager.repeatMode)
notificationComponent.updateRepeatMode(playbackManager.repeatMode)
}
startForegroundOrNotify()
@ -328,14 +300,15 @@ class PlaybackService :
override fun onShowCoverUpdate(showCovers: Boolean) {
playbackManager.song?.let { song ->
connector.onSongChanged(song)
notification.setMetadata(song, ::startForegroundOrNotify)
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
}
}
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
playbackManager.song?.let { song ->
notification.setMetadata(song, ::startForegroundOrNotify)
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
}
}
@ -398,20 +371,12 @@ class PlaybackService :
logD("Starting foreground/notifying")
if (!isForeground) {
// Specify that this is a media service, if supported.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
IntegerTable.NOTIFICATION_CODE,
notification.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(IntegerTable.NOTIFICATION_CODE, notification.build())
}
startForeground(IntegerTable.NOTIFICATION_CODE, notificationComponent.build())
isForeground = true
} else {
// If we are already in foreground just update the notification
notificationManager.notify(IntegerTable.NOTIFICATION_CODE, notification.build())
notificationComponent.renotify()
}
}
}
@ -424,18 +389,6 @@ class PlaybackService :
saveScope.launch { playbackManager.saveStateToDatabase(this@PlaybackService) }
}
data class Metadata(
val title: String,
val album: String,
val artist: String,
val album_artist: String,
val genre: String,
val parent: String,
val year: Int,
val track: Int?,
val albumCover: Bitmap
)
/** A [BroadcastReceiver] for receiving general playback events from the system. */
private inner class PlaybackReceiver : BroadcastReceiver() {
private var initialHeadsetPlugEventHandled = false

View file

@ -66,10 +66,6 @@ class WidgetController(private val context: Context) :
widget.update(context, playbackManager)
}
override fun onQueueChanged(index: Int, queue: List<Song>) {
widget.update(context, playbackManager)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
widget.update(context, playbackManager)
}