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, -)