diff --git a/app/src/main/java/org/oxycblt/auxio/coil/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/coil/BitmapProvider.kt
new file mode 100644
index 000000000..c69fcb4d7
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/BitmapProvider.kt
@@ -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 .
+ */
+
+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?)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt
index 213ef24f0..724f2589e 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/StyledImageView.kt
@@ -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 StyledImageView.load(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
+private fun 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 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())
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index 515ac37ff..3c588b007 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -15,6 +15,15 @@
* along with this program. If not, see .
*/
+@file:Suppress(
+ "PropertyName",
+ "PropertyName",
+ "PropertyName",
+ "PropertyName",
+ "PropertyName",
+ "PropertyName",
+ "PropertyName")
+
package org.oxycblt.auxio.music
import android.content.ContentUris
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt
deleted file mode 100644
index 0d6d2f9f1..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt
+++ /dev/null
@@ -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 .
- */
-
-package org.oxycblt.auxio.music
-
-
-
-
-
-
-// --- EXTENSION FUNCTIONS ---
diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt
index 56171f687..db2b94d6b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt
@@ -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) {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
index 61e314b22..4b0f73c12 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt
@@ -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()
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
index e7016b7af..8c6570276 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt
@@ -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()
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
index ca1e24a8e..a647b52b6 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
@@ -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, 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, 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
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
index 5de3fb177..d75f8a8ac 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt
@@ -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"
}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
index f6caf4084..aca16393a 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt
@@ -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, 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()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
index 4475a3f5d..cde1cd4f8 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
@@ -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() {
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
index 3a482b246..fc4b916a7 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt
@@ -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(
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
new file mode 100644
index 000000000..7e1ff844b
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
@@ -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 .
+ */
+
+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, 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,
+ )
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt
deleted file mode 100644
index 7bc06ce65..000000000
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt
+++ /dev/null
@@ -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 .
- */
-
-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, 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)
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
index 2fd233105..16978b8dc 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -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)
}
/*
diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetState.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetState.kt
deleted file mode 100644
index 6e9ba9534..000000000
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetState.kt
+++ /dev/null
@@ -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 .
- */
-
-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,
-)