playback: rework service components

Try to rework the playback service components to reduce race conditions
and improve readability.

This changeset has gone through a number of changes. I originally
wanted to unify all cover loading under a single "Component Manager",
but this turned out to be stupid given that the three service components
are different in nearly every way. Instead I just reworked them all
individually by introducing a new less-data race-prone image loading
framework, and moving around a bunch of code I was planning to move
around.
This commit is contained in:
OxygenCobalt 2022-05-16 20:37:42 -06:00
parent d296a3aed9
commit 4a79de455a
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 526 additions and 486 deletions

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.coil
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import org.oxycblt.auxio.music.Song
/**
* A utility to provide bitmaps in a manner less prone to race conditions.
*
* Pretty much each service component needs to load bitmaps of some kind, but doing a blind image
* request with some target callbacks could result in overlapping requests causing unrelated
* updates. This class (to an extent) resolves this by keeping track of the current request and
* disposes of it every time a new request is created. This greatly reduces the surface for race
* conditions save the case of instruction-by-instruction data races, which are effectively
* impossible to solve.
*
* @author OxygenCobalt
*/
class BitmapProvider(private val context: Context) {
private var currentRequest: Request? = null
val isBusy: Boolean
get() = currentRequest?.run { !disposable.isDisposed } ?: false
/**
* Load a bitmap from [song]. [target] should be a new object, not a reference to an existing
* callback.
*/
fun load(song: Song, target: Target) {
currentRequest?.run { disposable.dispose() }
currentRequest = null
val request =
target.setupRequest(
ImageRequest.Builder(context)
.data(song)
.size(Size.ORIGINAL)
.target(
onSuccess = { target.onCompleted(it.toBitmap()) },
onError = { target.onCompleted(null) })
.transformations(SquareFrameTransform.INSTANCE))
currentRequest = Request(context.imageLoader.enqueue(request.build()), target)
}
/**
* Release this instance, canceling all image load jobs. This should be ran when the object is
* no longer used.
*/
fun release() {
currentRequest?.run { disposable.dispose() }
currentRequest = null
}
private data class Request(val disposable: Disposable, val callback: Target)
interface Target {
fun setupRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
fun onCompleted(bitmap: Bitmap?)
}
}

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.coil
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
@ -27,12 +26,8 @@ import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.graphics.drawable.toBitmap
import coil.dispose
import coil.imageLoader
import coil.load
import coil.request.ImageRequest
import coil.size.Size
import com.google.android.material.shape.MaterialShapeDrawable
import kotlin.math.min
import org.oxycblt.auxio.R
@ -115,8 +110,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
// TODO: Borg the extension methods into the view, move the loadBitmap call to the service
// eventually
// TODO: Borg the extension methods into the view
/** Bind the album cover for a [song]. */
fun StyledImageView.bindAlbumCover(song: Song) =
@ -134,7 +128,11 @@ fun StyledImageView.bindArtistImage(artist: Artist) =
fun StyledImageView.bindGenreImage(genre: Genre) =
load(genre, R.drawable.ic_genre, R.string.desc_genre_image)
fun <T : Music> StyledImageView.load(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
private fun <T : Music> StyledImageView.load(
music: T,
@DrawableRes error: Int,
@StringRes desc: Int
) {
contentDescription = context.getString(desc, music.resolveName(context))
dispose()
load(music) {
@ -153,19 +151,3 @@ fun <T : Music> StyledImageView.load(music: T, @DrawableRes error: Int, @StringR
})
}
}
// --- OTHER FUNCTIONS ---
/**
* Get a bitmap for a [song]. [onDone] will be called with the loaded bitmap, or null if loading
* failed/shouldn't occur. **This not meant for UIs, instead use the Binding Adapters.**
*/
fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(song.album)
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform())
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
.build())
}

View file

@ -15,6 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress(
"PropertyName",
"PropertyName",
"PropertyName",
"PropertyName",
"PropertyName",
"PropertyName",
"PropertyName")
package org.oxycblt.auxio.music
import android.content.ContentUris

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
// --- EXTENSION FUNCTIONS ---

View file

@ -29,8 +29,8 @@ import org.oxycblt.auxio.util.requireBackgroundThread
/**
* Database for storing excluded directories. Note that the paths stored here will not work with
* MediaStore unless you append a "%" at the end. Yes. I know Room exists. But that would needlessly
* bloat my app and has crippling bugs. TODO: Migrate this to SharedPreferences?
* @author OxygenCobalt
* bloat my app and has crippling bugs.
* @author OxygenCobalt TODO: Migrate this to SharedPreferences?
*/
class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {

View file

@ -45,10 +45,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* provides an interface that properly sanitizes input and abstracts functions unlike the master
* class.**
* @author OxygenCobalt
*
* TODO: Completely rework this module to support the new music rescan system, proper android auto
* and external exposing, and so on.
* - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS.
*/
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private val musicStore = MusicStore.getInstance()

View file

@ -42,8 +42,12 @@ import org.oxycblt.auxio.util.logD
* All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt
*
* TODO: Add a controller role and move song loading/seeking to that TODO: Make PlaybackViewModel
* pass "delayed actions" to this and then await the service to start it???
* TODO: Add a controller role and move song loading/seeking to that
*
* TODO: Make PlaybackViewModel pass "delayed actions" to this and then await the service to start
* it???
*
* TODO: Bug test app behavior when playback stops
*/
class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance()

View file

@ -19,13 +19,14 @@ package org.oxycblt.auxio.playback.system
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
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.coil.BitmapProvider
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
@ -36,26 +37,23 @@ import org.oxycblt.auxio.util.logD
/**
*/
class MediaSessionComponent(private val context: Context, private val player: Player) :
PlaybackStateManager.Callback,
Player.Listener,
SettingsManager.Callback,
MediaSessionCompat.Callback() {
MediaSessionCompat.Callback(),
PlaybackStateManager.Callback,
SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val mediaSession = MediaSessionCompat(context, context.packageName)
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply { isActive = true }
private val provider = BitmapProvider(context)
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)
playbackManager.addCallback(this)
mediaSession.setCallback(this)
}
fun handleMediaButtonIntent(intent: Intent) {
@ -63,10 +61,111 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
}
fun release() {
provider.release()
player.removeListener(this)
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
player.removeListener(this)
mediaSession.release()
mediaSession.apply {
isActive = false
release()
}
}
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.song)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
updateMediaMetadata(playbackManager.song)
}
private fun updateMediaMetadata(song: Song?) {
if (song == null) {
mediaSession.setMetadata(emptyMetadata)
return
}
val title = song.resolveName(context)
val artist = song.resolveIndividualArtistName(context)
val metadata =
MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.artist.resolveName(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context))
.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track?.toLong() ?: 0L)
.putText(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year?.toString())
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
.putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
song.album.albumCoverUri.toString())
// Normally, android expects one to provide a URI to the metadata instance instead of
// a full blown bitmap. In practice, this is not ideal in the slightest, as we cannot
// provide any user customization or quality of life improvements with a flat URI.
// Instead, we load a full size bitmap and use it within it's respective fields.
provider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
mediaSession.setMetadata(metadata.build())
}
})
}
override fun onPlayingChanged(isPlaying: Boolean) {
invalidateSessionState()
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
// TODO: Add the custom actions for Android 13
mediaSession.setRepeatMode(
when (repeatMode) {
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
})
}
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) {
updateMediaMetadata(playbackManager.song)
}
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
updateMediaMetadata(playbackManager.song)
}
// --- EXOPLAYER CALLBACKS ---
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
invalidateSessionState()
}
// --- MEDIASESSION CALLBACKS ---
@ -88,7 +187,7 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
}
override fun onSeekTo(position: Long) {
player.seekTo(position)
playbackManager.seekTo(position)
}
override fun onRewind() {
@ -117,93 +216,13 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
}
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
override fun onIndexMoved(index: Int) {
onSongChanged(playbackManager.song)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
onSongChanged(playbackManager.song)
}
private fun onSongChanged(song: Song?) {
if (song == null) {
mediaSession.setMetadata(emptyMetadata)
return
}
val artistName = song.resolveIndividualArtistName(context)
val builder =
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolveName(context))
.putString(
MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolveName(context))
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artistName)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
// Load the cover asynchronously. This is the entire reason I don't use a plain
// MediaSessionConnector, which AFAIK makes it impossible to load this the way I do
// without a bunch of stupid race conditions.
loadBitmap(context, song) { bitmap ->
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
mediaSession.setMetadata(builder.build())
}
}
override fun onPlayingChanged(isPlaying: Boolean) {
invalidateSessionState()
}
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 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 ---
private fun invalidateSessionState() {
logD("Updating media session state")
logD("Updating media session playback state")
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
// Position updates arrive faster when you upload STATE_PAUSED, as it resets the clock
// that updates the position.
val state =
PlaybackStateCompat.Builder()
.setActions(ACTIONS)
@ -216,30 +235,22 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
mediaSession.setPlaybackState(state.build())
state.setState(
getPlayerState(), player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
val playerState =
if (playbackManager.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
}
state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
mediaSession.setPlaybackState(state.build())
}
private fun getPlayerState(): Int {
if (playbackManager.song == null) {
// No song, player should be stopped
return PlaybackStateCompat.STATE_STOPPED
}
// Otherwise play/pause
return if (playbackManager.isPlaying) {
PlaybackStateCompat.STATE_PLAYING
} else {
PlaybackStateCompat.STATE_PAUSED
}
}
companion object {
private val emptyMetadata = MediaMetadataCompat.Builder().build()
const val ACTIONS =
private const val ACTIONS =
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY_PAUSE or

View file

@ -21,6 +21,7 @@ import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import androidx.annotation.DrawableRes
@ -29,7 +30,7 @@ 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.coil.BitmapProvider
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
@ -44,9 +45,13 @@ import org.oxycblt.auxio.util.newMainIntent
* @author OxygenCobalt
*/
@SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
NotificationCompat.Builder(context, CHANNEL_ID) {
class NotificationComponent(
private val context: Context,
private val callback: Callback,
sessionToken: MediaSessionCompat.Token
) : NotificationCompat.Builder(context, CHANNEL_ID) {
private val notificationManager = context.getSystemServiceSafe(NotificationManager::class)
private val provider = BitmapProvider(context)
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -79,13 +84,14 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
notificationManager.notify(IntegerTable.NOTIFICATION_CODE, build())
}
fun release() {
provider.release()
}
// --- STATE FUNCTIONS ---
/**
* Set the metadata of the notification using [song].
* @param onDone What to do when the loading of the album art is finished
*/
fun updateMetadata(song: Song, parent: MusicParent?, onDone: () -> Unit) {
/** Set the metadata of the notification using [song]. */
fun updateMetadata(song: Song, parent: MusicParent?) {
setContentTitle(song.resolveName(context))
setContentText(song.resolveIndividualArtistName(context))
@ -97,27 +103,41 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
setSubText(song.resolveName(context))
}
// loadBitmap() is concurrent, so only call back to the object calling this function when
// the loading is over.
loadBitmap(context, song) { bitmap ->
setLargeIcon(bitmap)
onDone()
}
provider.load(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
setLargeIcon(bitmap)
callback.onNotificationChanged(this@NotificationComponent)
}
})
}
/** Set the playing icon on the notification */
fun updatePlaying(isPlaying: Boolean) {
mActions[2] = buildPlayPauseAction(context, isPlaying)
if (!provider.isBusy) {
callback.onNotificationChanged(this)
}
}
/** Update the first action to reflect the [repeatMode] given. */
fun updateRepeatMode(repeatMode: RepeatMode) {
mActions[0] = buildRepeatAction(context, repeatMode)
if (!provider.isBusy) {
callback.onNotificationChanged(this)
}
}
/** Update the first action to reflect whether the queue is shuffled or not */
fun updateShuffled(isShuffled: Boolean) {
mActions[0] = buildShuffleAction(context, isShuffled)
if (!provider.isBusy) {
callback.onNotificationChanged(this)
}
}
// --- NOTIFICATION ACTION BUILDERS ---
@ -167,6 +187,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes
return action.build()
}
interface Callback {
fun onNotificationChanged(component: NotificationComponent)
}
companion object {
const val CHANNEL_ID = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK"
}

View file

@ -51,7 +51,7 @@ 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
import org.oxycblt.auxio.widgets.WidgetController
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
/**
@ -63,13 +63,17 @@ 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: Synchronize components in a less awful way (Fix issue where rapid-fire updates results in a
* desynced notification)
* TODO: Android Auto
*
* @author OxygenCobalt
*/
class PlaybackService :
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
Service(),
Player.Listener,
NotificationComponent.Callback,
PlaybackStateManager.Callback,
SettingsManager.Callback {
// Player components
private lateinit var player: ExoPlayer
private val replayGainProcessor = ReplayGainAudioProcessor()
@ -77,7 +81,7 @@ class PlaybackService :
// System backend components
private lateinit var notificationComponent: NotificationComponent
private lateinit var mediaSessionComponent: MediaSessionComponent
private lateinit var widgets: WidgetController
private lateinit var widgetComponent: WidgetComponent
private val systemReceiver = PlaybackReceiver()
// Managers
@ -101,7 +105,7 @@ class PlaybackService :
// --- PLAYER SETUP ---
player = newPlayer()
player.addListener(this@PlaybackService)
player.addListener(this)
positionScope.launch {
while (true) {
@ -110,13 +114,27 @@ class PlaybackService :
}
}
// --- PLAYBACKSTATEMANAGER SETUP ---
playbackManager.addCallback(this)
if (playbackManager.isInitialized) {
loadSong(playbackManager.song)
onSeek(playbackManager.positionMs)
onPlayingChanged(playbackManager.isPlaying)
onShuffledChanged(playbackManager.isShuffled)
onRepeatChanged(playbackManager.repeatMode)
}
// --- SETTINGSMANAGER SETUP ---
settingsManager.addCallback(this)
// --- SYSTEM SETUP ---
widgets = WidgetController(this)
widgetComponent = WidgetComponent(this)
mediaSessionComponent = MediaSessionComponent(this, player)
notificationComponent = NotificationComponent(this, mediaSessionComponent.token)
notificationComponent = NotificationComponent(this, this, mediaSessionComponent.token)
// Then the notification/headset callbacks
IntentFilter().apply {
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(AudioManager.ACTION_HEADSET_PLUG)
@ -132,17 +150,6 @@ class PlaybackService :
registerReceiver(systemReceiver, this)
}
// --- PLAYBACKSTATEMANAGER SETUP ---
playbackManager.addCallback(this)
if (playbackManager.isInitialized) {
restore()
}
// --- SETTINGSMANAGER SETUP ---
settingsManager.addCallback(this)
logD("Service created")
}
@ -166,23 +173,23 @@ class PlaybackService :
stopForeground(true)
isForeground = false
unregisterReceiver(systemReceiver)
serviceJob.cancel()
mediaSessionComponent.release()
widgets.release()
player.release()
// Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
unregisterReceiver(systemReceiver)
serviceJob.cancel()
// Pause just in case this destruction was unexpected.
playbackManager.isPlaying = false
widgetComponent.release()
mediaSessionComponent.release()
notificationComponent.release()
player.release()
logD("Service destroyed")
}
// --- PLAYER EVENT LISTENER OVERRIDES ---
// --- PLAYER OVERRIDES ---
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
@ -244,14 +251,14 @@ class PlaybackService :
// --- PLAYBACK STATE CALLBACK OVERRIDES ---
override fun onIndexMoved(index: Int) {
onSongChanged(playbackManager.song)
loadSong(playbackManager.song)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
onSongChanged(playbackManager.song)
loadSong(playbackManager.song)
}
private fun onSongChanged(song: Song?) {
private fun loadSong(song: Song?) {
if (song == null) {
// Clear if there's nothing to play.
logD("Nothing playing, stopping playback")
@ -263,27 +270,24 @@ class PlaybackService :
logD("Loading ${song.rawName}")
player.setMediaItem(MediaItem.fromUri(song.uri))
player.prepare()
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
notificationComponent.updateMetadata(song, playbackManager.parent)
}
override fun onPlayingChanged(isPlaying: Boolean) {
player.playWhenReady = isPlaying
notificationComponent.updatePlaying(isPlaying)
startForegroundOrNotify()
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
if (!settingsManager.useAltNotifAction) {
notificationComponent.updateRepeatMode(repeatMode)
startForegroundOrNotify()
}
}
override fun onShuffledChanged(isShuffled: Boolean) {
logD("${settingsManager.useAltNotifAction}")
if (settingsManager.useAltNotifAction) {
notificationComponent.updateShuffled(isShuffled)
startForegroundOrNotify()
}
}
@ -293,32 +297,44 @@ class PlaybackService :
// --- SETTINGSMANAGER OVERRIDES ---
override fun onNotifActionUpdate(useAltAction: Boolean) {
if (useAltAction) {
notificationComponent.updateShuffled(playbackManager.isShuffled)
} else {
notificationComponent.updateRepeatMode(playbackManager.repeatMode)
}
startForegroundOrNotify()
override fun onReplayGainUpdate(mode: ReplayGainMode) {
onTracksInfoChanged(player.currentTracksInfo)
}
override fun onShowCoverUpdate(showCovers: Boolean) {
playbackManager.song?.let { song ->
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
notificationComponent.updateMetadata(song, playbackManager.parent)
}
}
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
playbackManager.song?.let { song ->
notificationComponent.updateMetadata(
song, playbackManager.parent, ::startForegroundOrNotify)
notificationComponent.updateMetadata(song, playbackManager.parent)
}
}
override fun onReplayGainUpdate(mode: ReplayGainMode) {
onTracksInfoChanged(player.currentTracksInfo)
override fun onNotifActionUpdate(useAltAction: Boolean) {
if (useAltAction) {
onShuffledChanged(playbackManager.isShuffled)
} else {
onRepeatChanged(playbackManager.repeatMode)
}
}
// --- NOTIFICATION CALLBACKS ---
override fun onNotificationChanged(component: NotificationComponent) {
if (hasPlayed && playbackManager.song != null) {
logD("Starting foreground/notifying")
if (!isForeground) {
startForeground(IntegerTable.NOTIFICATION_CODE, component.build())
isForeground = true
} else {
// If we are already in foreground just update the notification
notificationComponent.renotify()
}
}
}
// --- OTHER FUNCTIONS ---
@ -354,37 +370,6 @@ class PlaybackService :
.build()
}
/** Fully restore the notification and playback state */
private fun restore() {
logD("Restoring the service state")
onSongChanged(playbackManager.song)
onSeek(playbackManager.positionMs)
onPlayingChanged(playbackManager.isPlaying)
onShuffledChanged(playbackManager.isShuffled)
onRepeatChanged(playbackManager.repeatMode)
// Notify other classes that rely on this service to also update.
widgets.update()
}
/**
* Bring the service into the foreground and show the notification, or refresh the notification.
*/
private fun startForegroundOrNotify() {
if (hasPlayed && playbackManager.song != null) {
logD("Starting foreground/notifying")
if (!isForeground) {
startForeground(IntegerTable.NOTIFICATION_CODE, notificationComponent.build())
isForeground = true
} else {
// If we are already in foreground just update the notification
notificationComponent.renotify()
}
}
}
/** Stop the foreground state and hide the notification */
private fun stopAndSave() {
stopForeground(true)
@ -431,7 +416,7 @@ class PlaybackService :
playbackManager.isPlaying = false
stopAndSave()
}
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
}
}

View file

@ -46,7 +46,9 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat].
* @author OxygenCobalt
*
* TODO: Add option to restore state TODO: Add option to not restore state
* TODO: Add option to restore the previous state
*
* TODO: Add option to not restore state
*/
@Suppress("UNUSED")
class SettingsListFragment : PreferenceFragmentCompat() {

View file

@ -40,7 +40,7 @@ fun createDefaultWidget(context: Context): RemoteViews {
* The tiny widget is for an edge-case situation where a 2xN widget happens to be smaller than
* 100dp. It just shows the cover, titles, and a button.
*/
fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
fun createTinyWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews {
return createViews(context, R.layout.widget_tiny)
.applyMeta(context, state)
.applyPlayControls(context, state)
@ -51,7 +51,7 @@ fun createTinyWidget(context: Context, state: WidgetState): RemoteViews {
* generally because a Medium widget is too large for this widget size and a text-only widget is too
* small for this widget size.
*/
fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
fun createSmallWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews {
return createViews(context, R.layout.widget_small)
.applyCover(context, state)
.applyBasicControls(context, state)
@ -61,21 +61,21 @@ fun createSmallWidget(context: Context, state: WidgetState): RemoteViews {
* The medium widget is for 2x3 widgets and shows the cover art, title/artist, and three controls.
* This is the default widget configuration.
*/
fun createMediumWidget(context: Context, state: WidgetState): RemoteViews {
fun createMediumWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews {
return createViews(context, R.layout.widget_medium)
.applyMeta(context, state)
.applyBasicControls(context, state)
}
/** The wide widget is for Nx2 widgets and is like the small widget but with more controls. */
fun createWideWidget(context: Context, state: WidgetState): RemoteViews {
fun createWideWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews {
return createViews(context, R.layout.widget_wide)
.applyCover(context, state)
.applyFullControls(context, state)
}
/** The large widget is for 3x4 widgets and shows all metadata and controls. */
fun createLargeWidget(context: Context, state: WidgetState): RemoteViews {
fun createLargeWidget(context: Context, state: WidgetComponent.WidgetState): RemoteViews {
return createViews(context, R.layout.widget_large)
.applyMeta(context, state)
.applyFullControls(context, state)
@ -87,7 +87,10 @@ private fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews {
return views
}
private fun RemoteViews.applyMeta(context: Context, state: WidgetState): RemoteViews {
private fun RemoteViews.applyMeta(
context: Context,
state: WidgetComponent.WidgetState
): RemoteViews {
applyCover(context, state)
setTextViewText(R.id.widget_song, state.song.resolveName(context))
@ -96,9 +99,12 @@ private fun RemoteViews.applyMeta(context: Context, state: WidgetState): RemoteV
return this
}
private fun RemoteViews.applyCover(context: Context, state: WidgetState): RemoteViews {
if (state.albumArt != null) {
setImageViewBitmap(R.id.widget_cover, state.albumArt)
private fun RemoteViews.applyCover(
context: Context,
state: WidgetComponent.WidgetState
): RemoteViews {
if (state.cover != null) {
setImageViewBitmap(R.id.widget_cover, state.cover)
setContentDescription(
R.id.widget_cover,
context.getString(R.string.desc_album_cover, state.song.album.resolveName(context)))
@ -110,7 +116,10 @@ private fun RemoteViews.applyCover(context: Context, state: WidgetState): Remote
return this
}
private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState): RemoteViews {
private fun RemoteViews.applyPlayControls(
context: Context,
state: WidgetComponent.WidgetState
): RemoteViews {
setOnClickPendingIntent(
R.id.widget_play_pause, context.newBroadcastIntent(PlaybackService.ACTION_PLAY_PAUSE))
@ -125,7 +134,10 @@ private fun RemoteViews.applyPlayControls(context: Context, state: WidgetState):
return this
}
private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState): RemoteViews {
private fun RemoteViews.applyBasicControls(
context: Context,
state: WidgetComponent.WidgetState
): RemoteViews {
applyPlayControls(context, state)
setOnClickPendingIntent(
@ -137,7 +149,10 @@ private fun RemoteViews.applyBasicControls(context: Context, state: WidgetState)
return this
}
private fun RemoteViews.applyFullControls(context: Context, state: WidgetState): RemoteViews {
private fun RemoteViews.applyFullControls(
context: Context,
state: WidgetComponent.WidgetState
): RemoteViews {
applyBasicControls(context, state)
setOnClickPendingIntent(

View file

@ -0,0 +1,146 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.widgets
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import coil.request.ImageRequest
import coil.size.Size
import coil.transform.RoundedCornersTransformation
import kotlin.math.min
import org.oxycblt.auxio.coil.BitmapProvider
import org.oxycblt.auxio.coil.SquareFrameTransform
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.getDimenSizeSafe
/**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
* without being released.
*/
class WidgetComponent(private val context: Context) :
PlaybackStateManager.Callback, SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val widget = WidgetProvider()
private val provider = BitmapProvider(context)
init {
playbackManager.addCallback(this)
settingsManager.addCallback(this)
}
/*
* Force-update the widget.
*/
fun update() {
// Updating Auxio's widget is unlike the rest of Auxio for two reasons:
// 1. We can't use the typical primitives like ViewModels
// 2. The component range is far smaller, so we have to do some odd hacks to get
// the same UX.
// 3. RemoteView memory is limited, so we want to batch updates as much as physically
// possible.
val song = playbackManager.song
if (song == null) {
widget.update(context, null)
return
}
val isPlaying = playbackManager.isPlaying
val repeatMode = playbackManager.repeatMode
val isShuffled = playbackManager.isShuffled
provider.load(
song,
object : BitmapProvider.Target {
override fun setupRequest(builder: ImageRequest.Builder): ImageRequest.Builder {
// The widget has two distinct styles that we must transform the album art to
// accommodate:
// - Before Android 12, the widget has hard edges, so we don't need to round
// out the album art.
// - After Android 12, the widget has round edges, so we need to round out
// the album art. I dislike this, but it's mainly for stylistic cohesion.
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val metrics = context.resources.displayMetrics
// Use RoundedCornersTransformation. This is because our hack to get a 1:1
// aspect ratio on widget ImageViews doesn't actually result in a square
// ImageView, so clipToOutline won't work.
builder
.transformations(
SquareFrameTransform.INSTANCE,
RoundedCornersTransformation(
context
.getDimenSizeSafe(
android.R.dimen.system_app_widget_inner_radius)
.toFloat()))
// The output of RoundedCornersTransformation is dimension-dependent,
// so scale up the image to the screen size to ensure consistent radii.
.size(min(metrics.widthPixels, metrics.heightPixels))
} else {
// Note: Explicitly use the "original" size as without it the scaling logic
// in coil breaks down and results in an error.
builder.size(Size.ORIGINAL)
}
}
override fun onCompleted(bitmap: Bitmap?) {
val state = WidgetState(song, bitmap, isPlaying, repeatMode, isShuffled)
widget.update(context, state)
}
})
}
/*
* Release this instance, removing the callbacks and resetting all widgets
*/
fun release() {
provider.release()
widget.reset(context)
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
}
// --- CALLBACKS ---
override fun onIndexMoved(index: Int) = update()
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
override fun onPlayingChanged(isPlaying: Boolean) = update()
override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
override fun onShowCoverUpdate(showCovers: Boolean) = update()
override fun onQualityCoverUpdate(doQualityCovers: Boolean) = update()
/*
* An immutable condensed variant of the current playback state, used so that PlaybackStateManager
* does not need to be queried directly.
*/
data class WidgetState(
val song: Song,
val cover: Bitmap?,
val isPlaying: Boolean,
val repeatMode: RepeatMode,
val isShuffled: Boolean,
)
}

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.widgets
import android.content.Context
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
/**
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
* without being released.
*/
class WidgetController(private val context: Context) :
PlaybackStateManager.Callback, SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
private val widget = WidgetProvider()
init {
playbackManager.addCallback(this)
settingsManager.addCallback(this)
}
/*
* Force-update the widget.
*/
fun update() {
widget.update(context, playbackManager)
}
/*
* Release this instance, removing the callbacks and resetting all widgets
*/
fun release() {
logD("Releasing instance")
widget.reset(context)
playbackManager.removeCallback(this)
settingsManager.removeCallback(this)
}
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
override fun onIndexMoved(index: Int) {
widget.update(context, playbackManager)
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
widget.update(context, playbackManager)
}
override fun onPlayingChanged(isPlaying: Boolean) {
widget.update(context, playbackManager)
}
override fun onShuffledChanged(isShuffled: Boolean) {
widget.update(context, playbackManager)
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
widget.update(context, playbackManager)
}
// --- SETTINGSMANAGER CALLBACKS ---
override fun onShowCoverUpdate(showCovers: Boolean) {
widget.update(context, playbackManager)
}
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
widget.update(context, playbackManager)
}
}

View file

@ -23,22 +23,11 @@ import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.SizeF
import android.widget.RemoteViews
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Size
import coil.transform.RoundedCornersTransformation
import kotlin.math.min
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.coil.SquareFrameTransform
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.getDimenSizeSafe
import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -58,75 +47,22 @@ class WidgetProvider : AppWidgetProvider() {
/*
* Update the widget based on the playback state.
*/
fun update(context: Context, playbackManager: PlaybackStateManager) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val song = playbackManager.song
if (song == null) {
fun update(context: Context, state: WidgetComponent.WidgetState?) {
if (state == null) {
reset(context)
return
}
loadWidgetBitmap(context, song) { bitmap ->
val state =
WidgetState(
song,
bitmap,
playbackManager.isPlaying,
playbackManager.isShuffled,
playbackManager.repeatMode)
// Map each widget form to the cells where it would look at least okay.
val views =
mapOf(
SizeF(180f, 100f) to createTinyWidget(context, state),
SizeF(180f, 152f) to createSmallWidget(context, state),
SizeF(272f, 152f) to createWideWidget(context, state),
SizeF(180f, 270f) to createMediumWidget(context, state),
SizeF(272f, 270f) to createLargeWidget(context, state))
// Map each widget form to the cells where it would look at least okay.
val views =
mapOf(
SizeF(180f, 100f) to createTinyWidget(context, state),
SizeF(180f, 152f) to createSmallWidget(context, state),
SizeF(272f, 152f) to createWideWidget(context, state),
SizeF(180f, 270f) to createMediumWidget(context, state),
SizeF(272f, 270f) to createLargeWidget(context, state))
appWidgetManager.applyViewsCompat(context, views)
}
}
/**
* Custom function for loading bitmaps to the widget in a way that works with the widget
* ImageView instances.
*/
private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
val coverRequest =
ImageRequest.Builder(context)
.data(song.album)
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
// The widget has two distinct styles that we must transform the album art to accommodate:
// - Before Android 12, the widget has hard edges, so we don't need to round out the album
// art.
// - After Android 12, the widget has round edges, so we need to round out the album art.
// I dislike this, but it's mainly for stylistic cohesion.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Use RoundedCornersTransformation. This is because our hack to get a 1:1 aspect
// ratio on widget ImageViews doesn't actually result in a square ImageView, so
// clipToOutline won't work.
val transform =
RoundedCornersTransformation(
context
.getDimenSizeSafe(android.R.dimen.system_app_widget_inner_radius)
.toFloat())
// The output of RoundedCornersTransformation is dimension-dependent, so scale up the
// image to the screen size to ensure consistent radii.
val metrics = context.resources.displayMetrics
coverRequest
.transformations(SquareFrameTransform(), transform)
.size(min(metrics.widthPixels, metrics.heightPixels))
} else {
// Note: Explicitly use the "original" size as without it the scaling logic
// in coil breaks down and results in an error.
coverRequest.transformations(SquareFrameTransform()).size(Size.ORIGINAL)
}
context.imageLoader.enqueue(coverRequest.build())
AppWidgetManager.getInstance(context).applyViewsCompat(context, views)
}
/*

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.widgets
import android.graphics.Bitmap
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
/*
* An immutable condensed variant of the current playback state, used so that PlaybackStateManager
* does not need to be queried directly.
*/
data class WidgetState(
val song: Song,
val albumArt: Bitmap?,
val isPlaying: Boolean,
val isShuffled: Boolean,
val repeatMode: RepeatMode,
)