playback: rework service components
Try to rework the playback service components to reduce race conditions and improve readability. This changeset has gone through a number of changes. I originally wanted to unify all cover loading under a single "Component Manager", but this turned out to be stupid given that the three service components are different in nearly every way. Instead I just reworked them all individually by introducing a new less-data race-prone image loading framework, and moving around a bunch of code I was planning to move around.
This commit is contained in:
parent
d296a3aed9
commit
4a79de455a
16 changed files with 526 additions and 486 deletions
83
app/src/main/java/org/oxycblt/auxio/coil/BitmapProvider.kt
Normal file
83
app/src/main/java/org/oxycblt/auxio/coil/BitmapProvider.kt
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.coil
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.imageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A utility to provide bitmaps in a manner less prone to race conditions.
|
||||
*
|
||||
* Pretty much each service component needs to load bitmaps of some kind, but doing a blind image
|
||||
* request with some target callbacks could result in overlapping requests causing unrelated
|
||||
* updates. This class (to an extent) resolves this by keeping track of the current request and
|
||||
* disposes of it every time a new request is created. This greatly reduces the surface for race
|
||||
* conditions save the case of instruction-by-instruction data races, which are effectively
|
||||
* impossible to solve.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class BitmapProvider(private val context: Context) {
|
||||
private var currentRequest: Request? = null
|
||||
|
||||
val isBusy: Boolean
|
||||
get() = currentRequest?.run { !disposable.isDisposed } ?: false
|
||||
|
||||
/**
|
||||
* Load a bitmap from [song]. [target] should be a new object, not a reference to an existing
|
||||
* callback.
|
||||
*/
|
||||
fun load(song: Song, target: Target) {
|
||||
currentRequest?.run { disposable.dispose() }
|
||||
currentRequest = null
|
||||
|
||||
val request =
|
||||
target.setupRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song)
|
||||
.size(Size.ORIGINAL)
|
||||
.target(
|
||||
onSuccess = { target.onCompleted(it.toBitmap()) },
|
||||
onError = { target.onCompleted(null) })
|
||||
.transformations(SquareFrameTransform.INSTANCE))
|
||||
|
||||
currentRequest = Request(context.imageLoader.enqueue(request.build()), target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, canceling all image load jobs. This should be ran when the object is
|
||||
* no longer used.
|
||||
*/
|
||||
fun release() {
|
||||
currentRequest?.run { disposable.dispose() }
|
||||
currentRequest = null
|
||||
}
|
||||
|
||||
private data class Request(val disposable: Disposable, val callback: Target)
|
||||
|
||||
interface Target {
|
||||
fun setupRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
|
||||
fun onCompleted(bitmap: Bitmap?)
|
||||
}
|
||||
}
|
|
@ -18,7 +18,6 @@
|
|||
package org.oxycblt.auxio.coil
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
|
@ -27,12 +26,8 @@ import androidx.annotation.AttrRes
|
|||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.dispose
|
||||
import coil.imageLoader
|
||||
import coil.load
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -115,8 +110,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Borg the extension methods into the view, move the loadBitmap call to the service
|
||||
// eventually
|
||||
// TODO: Borg the extension methods into the view
|
||||
|
||||
/** Bind the album cover for a [song]. */
|
||||
fun StyledImageView.bindAlbumCover(song: Song) =
|
||||
|
@ -134,7 +128,11 @@ fun StyledImageView.bindArtistImage(artist: Artist) =
|
|||
fun StyledImageView.bindGenreImage(genre: Genre) =
|
||||
load(genre, R.drawable.ic_genre, R.string.desc_genre_image)
|
||||
|
||||
fun <T : Music> StyledImageView.load(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
|
||||
private fun <T : Music> StyledImageView.load(
|
||||
music: T,
|
||||
@DrawableRes error: Int,
|
||||
@StringRes desc: Int
|
||||
) {
|
||||
contentDescription = context.getString(desc, music.resolveName(context))
|
||||
dispose()
|
||||
load(music) {
|
||||
|
@ -153,19 +151,3 @@ fun <T : Music> StyledImageView.load(music: T, @DrawableRes error: Int, @StringR
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
/**
|
||||
* Get a bitmap for a [song]. [onDone] will be called with the loaded bitmap, or null if loading
|
||||
* failed/shouldn't occur. **This not meant for UIs, instead use the Binding Adapters.**
|
||||
*/
|
||||
fun loadBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) {
|
||||
context.imageLoader.enqueue(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song.album)
|
||||
.size(Size.ORIGINAL)
|
||||
.transformations(SquareFrameTransform())
|
||||
.target(onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) })
|
||||
.build())
|
||||
}
|
||||
|
|
|
@ -15,6 +15,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress(
|
||||
"PropertyName",
|
||||
"PropertyName",
|
||||
"PropertyName",
|
||||
"PropertyName",
|
||||
"PropertyName",
|
||||
"PropertyName",
|
||||
"PropertyName")
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.ContentUris
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// --- EXTENSION FUNCTIONS ---
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -19,13 +19,14 @@ package org.oxycblt.auxio.playback.system
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.SystemClock
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.google.android.exoplayer2.Player
|
||||
import org.oxycblt.auxio.coil.loadBitmap
|
||||
import org.oxycblt.auxio.coil.BitmapProvider
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
|
@ -36,26 +37,23 @@ import org.oxycblt.auxio.util.logD
|
|||
/**
|
||||
*/
|
||||
class MediaSessionComponent(private val context: Context, private val player: Player) :
|
||||
PlaybackStateManager.Callback,
|
||||
Player.Listener,
|
||||
SettingsManager.Callback,
|
||||
MediaSessionCompat.Callback() {
|
||||
MediaSessionCompat.Callback(),
|
||||
PlaybackStateManager.Callback,
|
||||
SettingsManager.Callback {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
|
||||
private val mediaSession = MediaSessionCompat(context, context.packageName)
|
||||
private val mediaSession =
|
||||
MediaSessionCompat(context, context.packageName).apply { isActive = true }
|
||||
private val provider = BitmapProvider(context)
|
||||
|
||||
val token: MediaSessionCompat.Token
|
||||
get() = mediaSession.sessionToken
|
||||
|
||||
init {
|
||||
mediaSession.setCallback(this)
|
||||
playbackManager.addCallback(this)
|
||||
settingsManager.addCallback(this)
|
||||
player.addListener(this)
|
||||
|
||||
onSongChanged(playbackManager.song)
|
||||
onPlayingChanged(playbackManager.isPlaying)
|
||||
playbackManager.addCallback(this)
|
||||
mediaSession.setCallback(this)
|
||||
}
|
||||
|
||||
fun handleMediaButtonIntent(intent: Intent) {
|
||||
|
@ -63,10 +61,111 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
|
|||
}
|
||||
|
||||
fun release() {
|
||||
provider.release()
|
||||
player.removeListener(this)
|
||||
playbackManager.removeCallback(this)
|
||||
settingsManager.removeCallback(this)
|
||||
player.removeListener(this)
|
||||
mediaSession.release()
|
||||
|
||||
mediaSession.apply {
|
||||
isActive = false
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
updateMediaMetadata(playbackManager.song)
|
||||
}
|
||||
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
|
||||
updateMediaMetadata(playbackManager.song)
|
||||
}
|
||||
|
||||
private fun updateMediaMetadata(song: Song?) {
|
||||
if (song == null) {
|
||||
mediaSession.setMetadata(emptyMetadata)
|
||||
return
|
||||
}
|
||||
|
||||
val title = song.resolveName(context)
|
||||
val artist = song.resolveIndividualArtistName(context)
|
||||
val metadata =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
|
||||
song.album.artist.resolveName(context))
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genre.resolveName(context))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track?.toLong() ?: 0L)
|
||||
.putText(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year?.toString())
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
|
||||
.putText(
|
||||
MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
|
||||
song.album.albumCoverUri.toString())
|
||||
|
||||
// Normally, android expects one to provide a URI to the metadata instance instead of
|
||||
// a full blown bitmap. In practice, this is not ideal in the slightest, as we cannot
|
||||
// provide any user customization or quality of life improvements with a flat URI.
|
||||
// Instead, we load a full size bitmap and use it within it's respective fields.
|
||||
provider.load(
|
||||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
mediaSession.setMetadata(metadata.build())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onPlayingChanged(isPlaying: Boolean) {
|
||||
invalidateSessionState()
|
||||
}
|
||||
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||
// TODO: Add the custom actions for Android 13
|
||||
mediaSession.setRepeatMode(
|
||||
when (repeatMode) {
|
||||
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
|
||||
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
|
||||
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
|
||||
})
|
||||
}
|
||||
|
||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
||||
mediaSession.setShuffleMode(
|
||||
if (isShuffled) {
|
||||
PlaybackStateCompat.SHUFFLE_MODE_ALL
|
||||
} else {
|
||||
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
||||
})
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER CALLBACKS ---
|
||||
|
||||
override fun onShowCoverUpdate(showCovers: Boolean) {
|
||||
updateMediaMetadata(playbackManager.song)
|
||||
}
|
||||
|
||||
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
|
||||
updateMediaMetadata(playbackManager.song)
|
||||
}
|
||||
|
||||
// --- EXOPLAYER CALLBACKS ---
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
invalidateSessionState()
|
||||
}
|
||||
|
||||
// --- MEDIASESSION CALLBACKS ---
|
||||
|
@ -88,7 +187,7 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
|
|||
}
|
||||
|
||||
override fun onSeekTo(position: Long) {
|
||||
player.seekTo(position)
|
||||
playbackManager.seekTo(position)
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
|
@ -117,93 +216,13 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
|
|||
context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT))
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
onSongChanged(playbackManager.song)
|
||||
}
|
||||
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
|
||||
onSongChanged(playbackManager.song)
|
||||
}
|
||||
|
||||
private fun onSongChanged(song: Song?) {
|
||||
if (song == null) {
|
||||
mediaSession.setMetadata(emptyMetadata)
|
||||
return
|
||||
}
|
||||
|
||||
val artistName = song.resolveIndividualArtistName(context)
|
||||
|
||||
val builder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.resolveName(context))
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, song.resolveName(context))
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_COMPOSER, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artistName)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context))
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.duration)
|
||||
|
||||
// Load the cover asynchronously. This is the entire reason I don't use a plain
|
||||
// MediaSessionConnector, which AFAIK makes it impossible to load this the way I do
|
||||
// without a bunch of stupid race conditions.
|
||||
loadBitmap(context, song) { bitmap ->
|
||||
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
mediaSession.setMetadata(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayingChanged(isPlaying: Boolean) {
|
||||
invalidateSessionState()
|
||||
}
|
||||
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||
mediaSession.setRepeatMode(
|
||||
when (repeatMode) {
|
||||
RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE
|
||||
RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE
|
||||
RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL
|
||||
})
|
||||
}
|
||||
|
||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
||||
mediaSession.setShuffleMode(
|
||||
if (isShuffled) {
|
||||
PlaybackStateCompat.SHUFFLE_MODE_ALL
|
||||
} else {
|
||||
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
||||
})
|
||||
}
|
||||
|
||||
// -- SETTINGSMANAGER CALLBACKS --
|
||||
|
||||
override fun onShowCoverUpdate(showCovers: Boolean) {
|
||||
onSongChanged(playbackManager.song)
|
||||
}
|
||||
|
||||
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
|
||||
onSongChanged(playbackManager.song)
|
||||
}
|
||||
|
||||
// -- EXOPLAYER CALLBACKS --
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
invalidateSessionState()
|
||||
}
|
||||
|
||||
// --- MISC ---
|
||||
|
||||
private fun invalidateSessionState() {
|
||||
logD("Updating media session state")
|
||||
logD("Updating media session playback state")
|
||||
|
||||
// Position updates arrive faster when you upload STATE_PAUSED for some insane reason.
|
||||
// Position updates arrive faster when you upload STATE_PAUSED, as it resets the clock
|
||||
// that updates the position.
|
||||
val state =
|
||||
PlaybackStateCompat.Builder()
|
||||
.setActions(ACTIONS)
|
||||
|
@ -216,30 +235,22 @@ class MediaSessionComponent(private val context: Context, private val player: Pl
|
|||
|
||||
mediaSession.setPlaybackState(state.build())
|
||||
|
||||
state.setState(
|
||||
getPlayerState(), player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
|
||||
val playerState =
|
||||
if (playbackManager.isPlaying) {
|
||||
PlaybackStateCompat.STATE_PLAYING
|
||||
} else {
|
||||
PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
|
||||
state.setState(playerState, player.currentPosition, 1.0f, SystemClock.elapsedRealtime())
|
||||
|
||||
mediaSession.setPlaybackState(state.build())
|
||||
}
|
||||
|
||||
private fun getPlayerState(): Int {
|
||||
if (playbackManager.song == null) {
|
||||
// No song, player should be stopped
|
||||
return PlaybackStateCompat.STATE_STOPPED
|
||||
}
|
||||
|
||||
// Otherwise play/pause
|
||||
return if (playbackManager.isPlaying) {
|
||||
PlaybackStateCompat.STATE_PLAYING
|
||||
} else {
|
||||
PlaybackStateCompat.STATE_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val emptyMetadata = MediaMetadataCompat.Builder().build()
|
||||
|
||||
const val ACTIONS =
|
||||
private const val ACTIONS =
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.widgets.WidgetController
|
||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||
|
||||
/**
|
||||
|
@ -63,13 +63,17 @@ import org.oxycblt.auxio.widgets.WidgetProvider
|
|||
*
|
||||
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so
|
||||
* therefore there's no need to bind to it to deliver commands.
|
||||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: Synchronize components in a less awful way (Fix issue where rapid-fire updates results in a
|
||||
* desynced notification)
|
||||
* TODO: Android Auto
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class PlaybackService :
|
||||
Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
Service(),
|
||||
Player.Listener,
|
||||
NotificationComponent.Callback,
|
||||
PlaybackStateManager.Callback,
|
||||
SettingsManager.Callback {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
private val replayGainProcessor = ReplayGainAudioProcessor()
|
||||
|
@ -77,7 +81,7 @@ class PlaybackService :
|
|||
// System backend components
|
||||
private lateinit var notificationComponent: NotificationComponent
|
||||
private lateinit var mediaSessionComponent: MediaSessionComponent
|
||||
private lateinit var widgets: WidgetController
|
||||
private lateinit var widgetComponent: WidgetComponent
|
||||
private val systemReceiver = PlaybackReceiver()
|
||||
|
||||
// Managers
|
||||
|
@ -101,7 +105,7 @@ class PlaybackService :
|
|||
// --- PLAYER SETUP ---
|
||||
|
||||
player = newPlayer()
|
||||
player.addListener(this@PlaybackService)
|
||||
player.addListener(this)
|
||||
|
||||
positionScope.launch {
|
||||
while (true) {
|
||||
|
@ -110,13 +114,27 @@ class PlaybackService :
|
|||
}
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER SETUP ---
|
||||
|
||||
playbackManager.addCallback(this)
|
||||
if (playbackManager.isInitialized) {
|
||||
loadSong(playbackManager.song)
|
||||
onSeek(playbackManager.positionMs)
|
||||
onPlayingChanged(playbackManager.isPlaying)
|
||||
onShuffledChanged(playbackManager.isShuffled)
|
||||
onRepeatChanged(playbackManager.repeatMode)
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER SETUP ---
|
||||
|
||||
settingsManager.addCallback(this)
|
||||
|
||||
// --- SYSTEM SETUP ---
|
||||
|
||||
widgets = WidgetController(this)
|
||||
widgetComponent = WidgetComponent(this)
|
||||
mediaSessionComponent = MediaSessionComponent(this, player)
|
||||
notificationComponent = NotificationComponent(this, mediaSessionComponent.token)
|
||||
notificationComponent = NotificationComponent(this, this, mediaSessionComponent.token)
|
||||
|
||||
// Then the notification/headset callbacks
|
||||
IntentFilter().apply {
|
||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
addAction(AudioManager.ACTION_HEADSET_PLUG)
|
||||
|
@ -132,17 +150,6 @@ class PlaybackService :
|
|||
registerReceiver(systemReceiver, this)
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER SETUP ---
|
||||
|
||||
playbackManager.addCallback(this)
|
||||
if (playbackManager.isInitialized) {
|
||||
restore()
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER SETUP ---
|
||||
|
||||
settingsManager.addCallback(this)
|
||||
|
||||
logD("Service created")
|
||||
}
|
||||
|
||||
|
@ -166,23 +173,23 @@ class PlaybackService :
|
|||
stopForeground(true)
|
||||
isForeground = false
|
||||
|
||||
unregisterReceiver(systemReceiver)
|
||||
|
||||
serviceJob.cancel()
|
||||
mediaSessionComponent.release()
|
||||
widgets.release()
|
||||
player.release()
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.isPlaying = false
|
||||
|
||||
playbackManager.removeCallback(this)
|
||||
settingsManager.removeCallback(this)
|
||||
unregisterReceiver(systemReceiver)
|
||||
serviceJob.cancel()
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.isPlaying = false
|
||||
widgetComponent.release()
|
||||
mediaSessionComponent.release()
|
||||
notificationComponent.release()
|
||||
player.release()
|
||||
|
||||
logD("Service destroyed")
|
||||
}
|
||||
|
||||
// --- PLAYER EVENT LISTENER OVERRIDES ---
|
||||
// --- PLAYER OVERRIDES ---
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
super.onPlayWhenReadyChanged(playWhenReady, reason)
|
||||
|
@ -244,14 +251,14 @@ class PlaybackService :
|
|||
// --- PLAYBACK STATE CALLBACK OVERRIDES ---
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
onSongChanged(playbackManager.song)
|
||||
loadSong(playbackManager.song)
|
||||
}
|
||||
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
|
||||
onSongChanged(playbackManager.song)
|
||||
loadSong(playbackManager.song)
|
||||
}
|
||||
|
||||
private fun onSongChanged(song: Song?) {
|
||||
private fun loadSong(song: Song?) {
|
||||
if (song == null) {
|
||||
// Clear if there's nothing to play.
|
||||
logD("Nothing playing, stopping playback")
|
||||
|
@ -263,27 +270,24 @@ class PlaybackService :
|
|||
logD("Loading ${song.rawName}")
|
||||
player.setMediaItem(MediaItem.fromUri(song.uri))
|
||||
player.prepare()
|
||||
notificationComponent.updateMetadata(
|
||||
song, playbackManager.parent, ::startForegroundOrNotify)
|
||||
notificationComponent.updateMetadata(song, playbackManager.parent)
|
||||
}
|
||||
|
||||
override fun onPlayingChanged(isPlaying: Boolean) {
|
||||
player.playWhenReady = isPlaying
|
||||
notificationComponent.updatePlaying(isPlaying)
|
||||
startForegroundOrNotify()
|
||||
}
|
||||
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||
if (!settingsManager.useAltNotifAction) {
|
||||
notificationComponent.updateRepeatMode(repeatMode)
|
||||
startForegroundOrNotify()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
||||
logD("${settingsManager.useAltNotifAction}")
|
||||
if (settingsManager.useAltNotifAction) {
|
||||
notificationComponent.updateShuffled(isShuffled)
|
||||
startForegroundOrNotify()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -293,32 +297,44 @@ class PlaybackService :
|
|||
|
||||
// --- SETTINGSMANAGER OVERRIDES ---
|
||||
|
||||
override fun onNotifActionUpdate(useAltAction: Boolean) {
|
||||
if (useAltAction) {
|
||||
notificationComponent.updateShuffled(playbackManager.isShuffled)
|
||||
} else {
|
||||
notificationComponent.updateRepeatMode(playbackManager.repeatMode)
|
||||
}
|
||||
|
||||
startForegroundOrNotify()
|
||||
override fun onReplayGainUpdate(mode: ReplayGainMode) {
|
||||
onTracksInfoChanged(player.currentTracksInfo)
|
||||
}
|
||||
|
||||
override fun onShowCoverUpdate(showCovers: Boolean) {
|
||||
playbackManager.song?.let { song ->
|
||||
notificationComponent.updateMetadata(
|
||||
song, playbackManager.parent, ::startForegroundOrNotify)
|
||||
notificationComponent.updateMetadata(song, playbackManager.parent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
|
||||
playbackManager.song?.let { song ->
|
||||
notificationComponent.updateMetadata(
|
||||
song, playbackManager.parent, ::startForegroundOrNotify)
|
||||
notificationComponent.updateMetadata(song, playbackManager.parent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReplayGainUpdate(mode: ReplayGainMode) {
|
||||
onTracksInfoChanged(player.currentTracksInfo)
|
||||
override fun onNotifActionUpdate(useAltAction: Boolean) {
|
||||
if (useAltAction) {
|
||||
onShuffledChanged(playbackManager.isShuffled)
|
||||
} else {
|
||||
onRepeatChanged(playbackManager.repeatMode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- NOTIFICATION CALLBACKS ---
|
||||
|
||||
override fun onNotificationChanged(component: NotificationComponent) {
|
||||
if (hasPlayed && playbackManager.song != null) {
|
||||
logD("Starting foreground/notifying")
|
||||
|
||||
if (!isForeground) {
|
||||
startForeground(IntegerTable.NOTIFICATION_CODE, component.build())
|
||||
isForeground = true
|
||||
} else {
|
||||
// If we are already in foreground just update the notification
|
||||
notificationComponent.renotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
@ -354,37 +370,6 @@ class PlaybackService :
|
|||
.build()
|
||||
}
|
||||
|
||||
/** Fully restore the notification and playback state */
|
||||
private fun restore() {
|
||||
logD("Restoring the service state")
|
||||
|
||||
onSongChanged(playbackManager.song)
|
||||
onSeek(playbackManager.positionMs)
|
||||
onPlayingChanged(playbackManager.isPlaying)
|
||||
onShuffledChanged(playbackManager.isShuffled)
|
||||
onRepeatChanged(playbackManager.repeatMode)
|
||||
|
||||
// Notify other classes that rely on this service to also update.
|
||||
widgets.update()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the service into the foreground and show the notification, or refresh the notification.
|
||||
*/
|
||||
private fun startForegroundOrNotify() {
|
||||
if (hasPlayed && playbackManager.song != null) {
|
||||
logD("Starting foreground/notifying")
|
||||
|
||||
if (!isForeground) {
|
||||
startForeground(IntegerTable.NOTIFICATION_CODE, notificationComponent.build())
|
||||
isForeground = true
|
||||
} else {
|
||||
// If we are already in foreground just update the notification
|
||||
notificationComponent.renotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the foreground state and hide the notification */
|
||||
private fun stopAndSave() {
|
||||
stopForeground(true)
|
||||
|
@ -431,7 +416,7 @@ class PlaybackService :
|
|||
playbackManager.isPlaying = false
|
||||
stopAndSave()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgets.update()
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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(
|
||||
|
|
146
app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
Normal file
146
app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt
Normal file
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.coil.BitmapProvider
|
||||
import org.oxycblt.auxio.coil.SquareFrameTransform
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.util.getDimenSizeSafe
|
||||
|
||||
/**
|
||||
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
|
||||
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
|
||||
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
|
||||
* without being released.
|
||||
*/
|
||||
class WidgetComponent(private val context: Context) :
|
||||
PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
private val widget = WidgetProvider()
|
||||
private val provider = BitmapProvider(context)
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
settingsManager.addCallback(this)
|
||||
}
|
||||
|
||||
/*
|
||||
* Force-update the widget.
|
||||
*/
|
||||
fun update() {
|
||||
// Updating Auxio's widget is unlike the rest of Auxio for two reasons:
|
||||
// 1. We can't use the typical primitives like ViewModels
|
||||
// 2. The component range is far smaller, so we have to do some odd hacks to get
|
||||
// the same UX.
|
||||
// 3. RemoteView memory is limited, so we want to batch updates as much as physically
|
||||
// possible.
|
||||
val song = playbackManager.song
|
||||
if (song == null) {
|
||||
widget.update(context, null)
|
||||
return
|
||||
}
|
||||
|
||||
val isPlaying = playbackManager.isPlaying
|
||||
val repeatMode = playbackManager.repeatMode
|
||||
val isShuffled = playbackManager.isShuffled
|
||||
|
||||
provider.load(
|
||||
song,
|
||||
object : BitmapProvider.Target {
|
||||
override fun setupRequest(builder: ImageRequest.Builder): ImageRequest.Builder {
|
||||
// The widget has two distinct styles that we must transform the album art to
|
||||
// accommodate:
|
||||
// - Before Android 12, the widget has hard edges, so we don't need to round
|
||||
// out the album art.
|
||||
// - After Android 12, the widget has round edges, so we need to round out
|
||||
// the album art. I dislike this, but it's mainly for stylistic cohesion.
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val metrics = context.resources.displayMetrics
|
||||
|
||||
// Use RoundedCornersTransformation. This is because our hack to get a 1:1
|
||||
// aspect ratio on widget ImageViews doesn't actually result in a square
|
||||
// ImageView, so clipToOutline won't work.
|
||||
builder
|
||||
.transformations(
|
||||
SquareFrameTransform.INSTANCE,
|
||||
RoundedCornersTransformation(
|
||||
context
|
||||
.getDimenSizeSafe(
|
||||
android.R.dimen.system_app_widget_inner_radius)
|
||||
.toFloat()))
|
||||
// The output of RoundedCornersTransformation is dimension-dependent,
|
||||
// so scale up the image to the screen size to ensure consistent radii.
|
||||
.size(min(metrics.widthPixels, metrics.heightPixels))
|
||||
} else {
|
||||
// Note: Explicitly use the "original" size as without it the scaling logic
|
||||
// in coil breaks down and results in an error.
|
||||
builder.size(Size.ORIGINAL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCompleted(bitmap: Bitmap?) {
|
||||
val state = WidgetState(song, bitmap, isPlaying, repeatMode, isShuffled)
|
||||
widget.update(context, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Release this instance, removing the callbacks and resetting all widgets
|
||||
*/
|
||||
fun release() {
|
||||
provider.release()
|
||||
widget.reset(context)
|
||||
playbackManager.removeCallback(this)
|
||||
settingsManager.removeCallback(this)
|
||||
}
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
||||
override fun onIndexMoved(index: Int) = update()
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
|
||||
override fun onPlayingChanged(isPlaying: Boolean) = update()
|
||||
override fun onShuffledChanged(isShuffled: Boolean) = update()
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) = update()
|
||||
override fun onShowCoverUpdate(showCovers: Boolean) = update()
|
||||
override fun onQualityCoverUpdate(doQualityCovers: Boolean) = update()
|
||||
|
||||
/*
|
||||
* An immutable condensed variant of the current playback state, used so that PlaybackStateManager
|
||||
* does not need to be queried directly.
|
||||
*/
|
||||
data class WidgetState(
|
||||
val song: Song,
|
||||
val cover: Bitmap?,
|
||||
val isPlaying: Boolean,
|
||||
val repeatMode: RepeatMode,
|
||||
val isShuffled: Boolean,
|
||||
)
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.content.Context
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
|
||||
* widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may
|
||||
* result in memory leaks if [PlaybackStateManager]/[SettingsManager] gets created and bound to
|
||||
* without being released.
|
||||
*/
|
||||
class WidgetController(private val context: Context) :
|
||||
PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settingsManager = SettingsManager.getInstance()
|
||||
private val widget = WidgetProvider()
|
||||
|
||||
init {
|
||||
playbackManager.addCallback(this)
|
||||
settingsManager.addCallback(this)
|
||||
}
|
||||
|
||||
/*
|
||||
* Force-update the widget.
|
||||
*/
|
||||
fun update() {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
/*
|
||||
* Release this instance, removing the callbacks and resetting all widgets
|
||||
*/
|
||||
fun release() {
|
||||
logD("Releasing instance")
|
||||
|
||||
widget.reset(context)
|
||||
playbackManager.removeCallback(this)
|
||||
settingsManager.removeCallback(this)
|
||||
}
|
||||
|
||||
// --- PLAYBACKSTATEMANAGER CALLBACKS ---
|
||||
|
||||
override fun onIndexMoved(index: Int) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
override fun onPlayingChanged(isPlaying: Boolean) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
// --- SETTINGSMANAGER CALLBACKS ---
|
||||
|
||||
override fun onShowCoverUpdate(showCovers: Boolean) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
|
||||
override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
|
||||
widget.update(context, playbackManager)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.widgets
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
|
||||
/*
|
||||
* An immutable condensed variant of the current playback state, used so that PlaybackStateManager
|
||||
* does not need to be queried directly.
|
||||
*/
|
||||
data class WidgetState(
|
||||
val song: Song,
|
||||
val albumArt: Bitmap?,
|
||||
val isPlaying: Boolean,
|
||||
val isShuffled: Boolean,
|
||||
val repeatMode: RepeatMode,
|
||||
)
|
Loading…
Reference in a new issue