shared: redocument

Redocument the shared module (previously ui).
This commit is contained in:
Alexander Capehart 2022-12-24 11:41:32 -07:00
parent b9210ecfe0
commit 18ba845302
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
19 changed files with 296 additions and 191 deletions

View file

@ -23,17 +23,17 @@ import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.shared.ServiceNotification import org.oxycblt.auxio.shared.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
/** /**
* A dynamic [ServiceNotification] that shows the current music loading state. * A dynamic [ForegroundServiceNotification] that shows the current music loading state.
* @param context [Context] required to create the notification. * @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IndexingNotification(private val context: Context) : class IndexingNotification(private val context: Context) :
ServiceNotification(context, INDEXER_CHANNEL) { ForegroundServiceNotification(context, INDEXER_CHANNEL) {
private var lastUpdateTime = -1L private var lastUpdateTime = -1L
init { init {
@ -89,11 +89,11 @@ class IndexingNotification(private val context: Context) :
} }
/** /**
* A static [ServiceNotification] that signals to the user that the app is currently monitoring * A static [ForegroundServiceNotification] that signals to the user that the app is currently monitoring
* the music library for changes. * the music library for changes.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) { class ObservingNotification(context: Context) : ForegroundServiceNotification(context, INDEXER_CHANNEL) {
init { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -111,5 +111,5 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val INDEXER_CHANNEL = private val INDEXER_CHANNEL =
ServiceNotification.ChannelInfo( ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -25,7 +25,7 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.shared.AuxioBottomSheetBehavior import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getDimen
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioBottomSheetBehavior<V>(context, attributeSet) { BaseBottomSheetBehavior<V>(context, attributeSet) {
val sheetBackgroundDrawable = val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface) fillColor = context.getAttrColorCompat(R.attr.colorSurface)

View file

@ -24,7 +24,7 @@ import android.view.WindowInsets
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.shared.AuxioBottomSheetBehavior import org.oxycblt.auxio.playback.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioBottomSheetBehavior<V>(context, attributeSet) { BaseBottomSheetBehavior<V>(context, attributeSet) {
private var barHeight = 0 private var barHeight = 0
private var barSpacing = context.getDimenPixels(R.dimen.spacing_small) private var barSpacing = context.getDimenPixels(R.dimen.spacing_small)

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.shared.ServiceNotification import org.oxycblt.auxio.shared.ForegroundServiceNotification
import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newBroadcastPendingIntent
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent
*/ */
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) : class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
ServiceNotification(context, CHANNEL_INFO) { ForegroundServiceNotification(context, CHANNEL_INFO) {
init { init {
setSmallIcon(R.drawable.ic_auxio_24) setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_TRANSPORT) setCategory(NotificationCompat.CATEGORY_TRANSPORT)

View file

@ -451,7 +451,7 @@ class PlaybackService :
playbackManager.changePlaying(false) playbackManager.changePlaying(false)
stopAndSave() stopAndSave()
} }
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.updateNowPlaying() WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.shared package org.oxycblt.auxio.playback.ui
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@ -30,24 +30,34 @@ import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.systemGestureInsetsCompat import org.oxycblt.auxio.util.systemGestureInsetsCompat
/** /**
* Implements a reasonable enough skeleton around BottomSheetBehavior (Excluding auxio extensions in * A BottomSheetBehavior that resolves several issues with the default implementation, including:
* the vendored code because of course I have to) for normal use without absurd bugs. * 1.
* @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class AuxioBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : abstract class BaseBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
NeoBottomSheetBehavior<V>(context, attributeSet) { NeoBottomSheetBehavior<V>(context, attributeSet) {
private var setup = false private var initalized = false
init { init {
// We need to disable isFitToContents for us to have our bottom sheet expand to the // Disable isFitToContents to make the bottom sheet expand to the top of the screen and
// whole of the screen and not just whatever portion it takes up. // not just how much the content takes up.
isFitToContents = false isFitToContents = false
} }
/** Called when the sheet background is being created */ /**
* Create a background [Drawable] to use for this [BaseBottomSheetBehavior]'s child [View].
* @param context [Context] that can be used to draw the [Drawable].
* @return A background drawable.
*/
abstract fun createBackground(context: Context): Drawable abstract fun createBackground(context: Context): Drawable
/** Called when the child the bottom sheet applies to receives window insets. */ /**
* Called when window insets are being applied to the [View] this [BaseBottomSheetBehavior]
* is linked to.
* @param child The child view recieving the [WindowInsets].
* @param insets The [WindowInsets] to apply.
* @return The (possibly modified) [WindowInsets].
* @see View.onApplyWindowInsets
*/
open fun applyWindowInsets(child: View, insets: WindowInsets): WindowInsets { open fun applyWindowInsets(child: View, insets: WindowInsets): WindowInsets {
// All sheet behaviors derive their peek height from the size of the "bar" (i.e the // All sheet behaviors derive their peek height from the size of the "bar" (i.e the
// first child) plus the gesture insets. // first child) plus the gesture insets.
@ -56,8 +66,7 @@ abstract class AuxioBottomSheetBehavior<V : View>(context: Context, attributeSet
return insets return insets
} }
// Enable experimental settings to allow us to skip the half expanded state without // Enable experimental settings that allow us to skip the half-expanded state.
// dumb hacks.
override fun shouldSkipHalfExpandedStateWhenDragging() = true override fun shouldSkipHalfExpandedStateWhenDragging() = true
override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) = override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) =
true true
@ -65,18 +74,21 @@ abstract class AuxioBottomSheetBehavior<V : View>(context: Context, attributeSet
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
val layout = super.onLayoutChild(parent, child, layoutDirection) val layout = super.onLayoutChild(parent, child, layoutDirection)
if (!setup) { // Don't repeat redundant initialization.
if (!initalized) {
child.apply { child.apply {
// Set up compat elevation attributes. These are only shown below API 28.
translationZ = context.getDimen(R.dimen.elevation_normal) translationZ = context.getDimen(R.dimen.elevation_normal)
// Background differs depending on concrete implementation.
background = createBackground(context) background = createBackground(context)
setOnApplyWindowInsetsListener(::applyWindowInsets) setOnApplyWindowInsetsListener(::applyWindowInsets)
} }
setup = true initalized = true
} }
// Sometimes CoordinatorLayout tries to be "hElpfUl" and just does not dispatch window // Sometimes CoordinatorLayout doesn't dispatch window insets to us, likely due to how
// insets sometimes. Ensure that we get them. // much we overload it. Ensure that we get them.
child.requestApplyInsets() child.requestApplyInsets()
return layout return layout

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.shared package org.oxycblt.auxio.playback.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet

View file

@ -79,7 +79,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
menu.findItem(searchModel.getFilterOptionId()).isChecked = true menu.findItem(searchModel.getFilterOptionId()).isChecked = true
setNavigationOnClickListener { setNavigationOnClickListener {
// Keyboard is no longer needed, drop it. // Keyboard is no longer needed.
imm.hide() imm.hide()
findNavController().navigateUp() findNavController().navigateUp()
} }
@ -126,6 +126,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
if (item.itemId != R.id.submenu_filtering) { if (item.itemId != R.id.submenu_filtering) {
// Is a change in filter mode and not just a junk submenu click, update // Is a change in filter mode and not just a junk submenu click, update
// the filtering within SearchViewModel. // the filtering within SearchViewModel.
item.isChecked = true
searchModel.setFilterOptionId(item.itemId) searchModel.setFilterOptionId(item.itemId)
return true return true
} }
@ -182,16 +183,16 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) is Genre -> SearchFragmentDirections.actionShowGenre(item.uid)
else -> return else -> return
} }
// Keyboard is no longer needed.
findNavController().navigate(action)
// Drop keyboard as it's no longer needed
imm.hide() imm.hide()
findNavController().navigate(action)
} }
private fun updateSelection(selected: List<Music>) { private fun updateSelection(selected: List<Music>) {
searchAdapter.setSelectedItems(selected) searchAdapter.setSelectedItems(selected)
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) { selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard.
imm.hide() imm.hide()
} }
} }

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.search
import android.app.Application import android.app.Application
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.text.Normalizer import java.text.Normalizer
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -76,7 +75,9 @@ class SearchViewModel(application: Application) :
} }
/** /**
* Use [query] to perform a search of the music library. Will push results to [searchResults]. * Asynchronously search the music library. Results will be pushed to [searchResults]. Will
* cancel any previous search operations started prior.
* @param query The query to search the music library for.
*/ */
fun search(query: String?) { fun search(query: String?) {
// Cancel the previous background search. // Cancel the previous background search.
@ -107,30 +108,30 @@ class SearchViewModel(application: Application) :
// Note: A null filter mode maps to the "All" filter option, hence the check. // Note: A null filter mode maps to the "All" filter option, hence the check.
if (filterMode == null || filterMode == MusicMode.ARTISTS) { if (filterMode == null || filterMode == MusicMode.ARTISTS) {
library.artists.filterArtistsBy(query)?.let { artists -> library.artists.searchListImpl(query)?.let {
results.add(Header(R.string.lbl_artists)) results.add(Header(R.string.lbl_artists))
results.addAll(sort.artists(artists)) results.addAll(sort.artists(it))
} }
} }
if (filterMode == null || filterMode == MusicMode.ALBUMS) { if (filterMode == null || filterMode == MusicMode.ALBUMS) {
library.albums.filterAlbumsBy(query)?.let { albums -> library.albums.searchListImpl(query)?.let {
results.add(Header(R.string.lbl_albums)) results.add(Header(R.string.lbl_albums))
results.addAll(sort.albums(albums)) results.addAll(sort.albums(it))
} }
} }
if (filterMode == null || filterMode == MusicMode.GENRES) { if (filterMode == null || filterMode == MusicMode.GENRES) {
library.genres.filterGenresBy(query)?.let { genres -> library.genres.searchListImpl(query)?.let {
results.add(Header(R.string.lbl_genres)) results.add(Header(R.string.lbl_genres))
results.addAll(sort.genres(genres)) results.addAll(sort.genres(it))
} }
} }
if (filterMode == null || filterMode == MusicMode.SONGS) { if (filterMode == null || filterMode == MusicMode.SONGS) {
library.songs.filterSongsBy(query)?.let { songs -> library.songs.searchListImpl(query) { q, song -> song.path.name.contains(q) }?.let {
results.add(Header(R.string.lbl_songs)) results.add(Header(R.string.lbl_songs))
results.addAll(sort.songs(songs)) results.addAll(sort.songs(it))
} }
} }
@ -138,26 +139,18 @@ class SearchViewModel(application: Application) :
return results return results
} }
private fun List<Song>.filterSongsBy(value: String) = /**
searchListImpl(value) { * Search a given [Music] list.
// Include both the sort name (can have normalized versions of titles) and * @param query The query to search for. The routine will compare this query to the names
// file name (helpful for poorly tagged songs) to the filtering. * of each object in the list and
it.rawSortName?.contains(value, ignoreCase = true) == true || * @param fallback Additional comparison code to run if the item does not match the query
it.path.name.contains(value) * initially. This can be used to compare against additional attributes to improve search
} * result quality.
*/
private fun List<Album>.filterAlbumsBy(value: String) = private inline fun <T : Music> List<T>.searchListImpl(
// Include the sort name (can have normalized versions of names) to the filtering. query: String,
searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true } fallback: (String, T) -> Boolean = { _, _ -> false }
) = filter {
private fun List<Artist>.filterArtistsBy(value: String) =
// Include the sort name (can have normalized versions of names) to the filtering.
searchListImpl(value) { it.rawSortName?.contains(value, ignoreCase = true) == true }
private fun List<Genre>.filterGenresBy(value: String) = searchListImpl(value) { false }
private inline fun <T : Music> List<T>.searchListImpl(query: String, fallback: (T) -> Boolean) =
filter {
// See if the plain resolved name matches the query. This works for most situations. // See if the plain resolved name matches the query. This works for most situations.
val name = it.resolveName(context) val name = it.resolveName(context)
if (name.contains(query, ignoreCase = true)) { if (name.contains(query, ignoreCase = true)) {
@ -180,7 +173,7 @@ class SearchViewModel(application: Application) :
return@filter true return@filter true
} }
fallback(it) fallback(query, it)
} }
.ifEmpty { null } .ifEmpty { null }

View file

@ -31,10 +31,11 @@ import com.google.android.material.appbar.AppBarLayout
import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.coordinatorLayoutBehavior
/** /**
* An [AppBarLayout] that fixes several bugs with the default implementation where the lifted state * An [AppBarLayout] that resolves two issues with the default implementation:
* will not properly respond to RecyclerView events. * 1. Lift state failing to update when list data changes.
* 2. Expansion causing jumping in [RecyclerView] instances.
* *
* **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what * Note: This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what
* scrolling view to use. Failure to specify this will result in the layout not working. * scrolling view to use. Failure to specify this will result in the layout not working.
* *
* Derived from Material Files: https://github.com/zhanghai/MaterialFiles * Derived from Material Files: https://github.com/zhanghai/MaterialFiles
@ -46,8 +47,8 @@ open class AuxioAppBarLayout
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppBarLayout(context, attrs, defStyleAttr) { AppBarLayout(context, attrs, defStyleAttr) {
private var scrollingChild: View? = null private var scrollingChild: View? = null
private val tConsumed = IntArray(2)
private val tConsumed = IntArray(2)
private val onPreDraw = private val onPreDraw =
ViewTreeObserver.OnPreDrawListener { ViewTreeObserver.OnPreDrawListener {
val child = findScrollingChild() val child = findScrollingChild()
@ -67,7 +68,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
/** /**
* Expand this app bar layout with the given recyclerview, preventing it from jumping around. * Expand this [AppBarLayout] with respect to the given [RecyclerView], preventing it from
* jumping around.
* @param recycler [RecyclerView] to expand with, or null if one is currently unavailable.
* TODO: Is it possible to use liftOnScrollTargetViewId to avoid the [RecyclerView] argument?
*/ */
fun expandWithRecycler(recycler: RecyclerView?) { fun expandWithRecycler(recycler: RecyclerView?) {
setExpanded(true) setExpanded(true)
@ -82,7 +86,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) { override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) {
super.setLiftOnScrollTargetViewId(liftOnScrollTargetViewId) super.setLiftOnScrollTargetViewId(liftOnScrollTargetViewId)
// Sometimes we dynamically set the scrolling child [such as in HomeFragment], so clear it // Sometimes we dynamically set the scrolling child [such as in HomeFragment], so clear it
// and re-draw when that occurs. // and re-draw when that occurs.
scrollingChild = null scrollingChild = null
@ -103,26 +106,40 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return scrollingChild return scrollingChild
} }
/** Hack to prevent RecyclerView jumping when the appbar expands. */ /**
* An [AppBarLayout.OnOffsetChangedListener] that will automatically move the given
* [RecyclerView] as the [AppBarLayout] expands. Should be added right when the view
* is expanding. Will be removed automatically.
* @param recycler [RecyclerView] to scroll with the [AppBarLayout].
*/
private class ExpansionHackListener(private val recycler: RecyclerView) : private class ExpansionHackListener(private val recycler: RecyclerView) :
OnOffsetChangedListener { OnOffsetChangedListener {
private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() + 600) private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() +
APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION)
private var lastVerticalOffset: Int? = null private var currentVerticalOffset: Int? = null
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
if (verticalOffset == 0 || if (verticalOffset == 0 ||
AnimationUtils.currentAnimationTimeMillis() > offsetAnimationMaxEndTime) { AnimationUtils.currentAnimationTimeMillis() > offsetAnimationMaxEndTime) {
// AppBarLayout crashes with IndexOutOfBoundsException when a non-last listener // AppBarLayout crashes with IndexOutOfBoundsException when a non-last listener
// removes // removes itself, so we have to do the removal asynchronously.
// itself, so we have to do the removal asynchronously. appBarLayout.postOnAnimation {
appBarLayout.postOnAnimation { appBarLayout.removeOnOffsetChangedListener(this) } appBarLayout.removeOnOffsetChangedListener(this) }
} }
val lastVerticalOffset = lastVerticalOffset
this.lastVerticalOffset = verticalOffset // If possible, scroll by the offset delta between this update and the last update.
if (lastVerticalOffset != null) { val oldVerticalOffset = currentVerticalOffset
recycler.scrollBy(0, verticalOffset - lastVerticalOffset) currentVerticalOffset = verticalOffset
if (oldVerticalOffset != null) {
recycler.scrollBy(0, verticalOffset - oldVerticalOffset)
} }
} }
} }
companion object {
/**
* @see AppBarLayout.BaseBehavior.MAX_OFFSET_ANIMATION_DURATION
*/
private const val APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION = 600
}
} }

View file

@ -22,47 +22,55 @@ import androidx.core.app.ServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* Wrapper to create consistent behavior regarding a service's foreground state. * A utility to create consistent foreground behavior for a given [Service].
* @param service [Service] to wrap in this instance.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Merge with unified service when done.
*/ */
class ForegroundManager(private val service: Service) { class ForegroundManager(private val service: Service) {
private var isForeground = false private var isForeground = false
/**
* Release this instance.
*/
fun release() { fun release() {
tryStopForeground() tryStopForeground()
} }
/** /**
* Try to enter a foreground state. Returns false if already in foreground, returns true if * Try to enter a foreground state.
* state was entered. * @param notification The [ForegroundServiceNotification] to show in order to signal the foreground
* state.
* @return true if the state was changed, false otherwise
* @see Service.startForeground
*/ */
fun tryStartForeground(notification: ServiceNotification): Boolean { fun tryStartForeground(notification: ForegroundServiceNotification): Boolean {
if (isForeground) { if (isForeground) {
// Nothing to do.
return false return false
} }
logD("Starting foreground state") logD("Starting foreground state")
service.startForeground(notification.code, notification.build()) service.startForeground(notification.code, notification.build())
isForeground = true isForeground = true
return true return true
} }
/** /**
* Try to stop a foreground state. Returns false if already in backend, returns true if state * Try to exit a foreground state. Will remove the foreground notification.
* was stopped. * @return true if the state was changed, false otherwise
* @see Service.stopForeground
*/ */
fun tryStopForeground(): Boolean { fun tryStopForeground(): Boolean {
if (!isForeground) { if (!isForeground) {
// Nothing to do.
return false return false
} }
logD("Stopping foreground state") logD("Stopping foreground state")
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false isForeground = false
return true return true
} }
} }

View file

@ -24,15 +24,17 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
/** /**
* Wrapper around [NotificationCompat.Builder] that automates parts of the notification setup, under * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* the assumption that the notification will be used in a service. * signal a Service's ongoing foreground state.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class ServiceNotification(context: Context, info: ChannelInfo) : abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) { NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context) private val notificationManager = NotificationManagerCompat.from(context)
init { init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel = val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes)) .setName(context.getString(info.nameRes))
@ -40,18 +42,29 @@ abstract class ServiceNotification(context: Context, info: ChannelInfo) :
.setVibrationEnabled(false) .setVibrationEnabled(false)
.setShowBadge(false) .setShowBadge(false)
.build() .build()
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
/**
* The code used to identify this notification.
* @see NotificationManagerCompat.notify
*/
abstract val code: Int abstract val code: Int
@Suppress("MissingPermission") /**
* Post this notification using [NotificationManagerCompat].
*/
fun post() { fun post() {
// This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground // This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground
// notification. // notification.
@Suppress("MissingPermission")
notificationManager.notify(code, build()) notificationManager.notify(code, build())
} }
/**
* Reduced representation of a [NotificationChannelCompat].
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int) data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
} }

View file

@ -26,35 +26,40 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A ViewModel that handles complicated navigation situations. * A [ViewModel] that handles complicated navigation functionality.
* @author Alexander Capehart (OxygenCobalt)
*/ */
class NavigationViewModel : ViewModel() { class NavigationViewModel : ViewModel() {
private val _mainNavigationAction = MutableStateFlow<MainNavigationAction?>(null) private val _mainNavigationAction = MutableStateFlow<MainNavigationAction?>(null)
/**
/** Flag for main fragment navigation. Intended for MainFragment use only. */ * Flag for navigation within the main navigation graph. Only intended for use by
* MainFragment.
*/
val mainNavigationAction: StateFlow<MainNavigationAction?> val mainNavigationAction: StateFlow<MainNavigationAction?>
get() = _mainNavigationAction get() = _mainNavigationAction
private val _exploreNavigationItem = MutableStateFlow<Music?>(null) private val _exploreNavigationItem = MutableStateFlow<Music?>(null)
/** /**
* Flag for navigation within the explore fragments. Observe this to coordinate navigation to an * Flag for navigation within the explore navigation graph. Observe this to coordinate
* item's UI. * navigation to a specific [Music] item.
*/ */
val exploreNavigationItem: StateFlow<Music?> val exploreNavigationItem: StateFlow<Music?>
get() = _exploreNavigationItem get() = _exploreNavigationItem
private val _exploreNavigationArtists = MutableStateFlow<List<Artist>?>(null) private val _exploreNavigationArtists = MutableStateFlow<List<Artist>?>(null)
/** /**
* Flag for navigation within the explore fragments. In this case, it involves an ambiguous list * Variation of [exploreNavigationItem] for situations where the choice of [Artist]
* of artist choices. * to navigate to is ambiguous. Only intended for use by MainFragment, as the resolved
* choice will eventually be assigned to [exploreNavigationItem].
*/ */
val exploreNavigationArtists: StateFlow<List<Artist>?> val exploreNavigationArtists: StateFlow<List<Artist>?>
get() = _exploreNavigationArtists get() = _exploreNavigationArtists
/** Notify MainFragment to navigate to the location outlined in [MainNavigationAction]. */ /**
* Navigate to something in the main navigation graph. This can be used by UIs in the explore
* navigation graph to trigger navigation in the higher-level main navigation graph.
* Will do nothing if already navigating.
* @param action The [MainNavigationAction] to perform.
*/
fun mainNavigateTo(action: MainNavigationAction) { fun mainNavigateTo(action: MainNavigationAction) {
if (_mainNavigationAction.value != null) { if (_mainNavigationAction.value != null) {
logD("Already navigating, not doing main action") logD("Already navigating, not doing main action")
@ -65,13 +70,20 @@ class NavigationViewModel : ViewModel() {
_mainNavigationAction.value = action _mainNavigationAction.value = action
} }
/** Mark that the main navigation process is done. */ /**
* Mark that the navigation process within the main navigation graph (initiated by
* [mainNavigateTo]) was completed.
*/
fun finishMainNavigation() { fun finishMainNavigation() {
logD("Finishing main navigation process") logD("Finishing main navigation process")
_mainNavigationAction.value = null _mainNavigationAction.value = null
} }
/** Navigate to an item's detail menu, whether a song/album/artist */ /**
* Navigate to a given [Music] item. Will do nothing if already navigating.
* @param item The [Music] to navigate to.
* TODO: Extend to song properties???
*/
fun exploreNavigateTo(item: Music) { fun exploreNavigateTo(item: Music) {
if (_exploreNavigationItem.value != null) { if (_exploreNavigationItem.value != null) {
logD("Already navigating, not doing explore action") logD("Already navigating, not doing explore action")
@ -82,22 +94,29 @@ class NavigationViewModel : ViewModel() {
_exploreNavigationItem.value = item _exploreNavigationItem.value = item
} }
/** Navigate to one item out of a list of items. */ /**
fun exploreNavigateTo(items: List<Artist>) { * Navigate to an [Artist] out of a list of [Artist]s, like [exploreNavigateTo].
* @param artists The [Artist]s to navigate to. In the case of multiple artists, the
* user will be prompted with a choice on which [Artist] to navigate to.
*/
fun exploreNavigateTo(artists: List<Artist>) {
if (_exploreNavigationArtists.value != null) { if (_exploreNavigationArtists.value != null) {
logD("Already navigating, not doing explore action") logD("Already navigating, not doing explore action")
return return
} }
if (items.size == 1) { if (artists.size == 1) {
exploreNavigateTo(items[0]) exploreNavigateTo(artists[0])
} else { } else {
logD("Navigating to a choice of ${items.map { it.rawName }}") logD("Navigating to a choice of ${artists.map { it.rawName }}")
_exploreNavigationArtists.value = items _exploreNavigationArtists.value = artists
} }
} }
/** Mark that the item navigation process is done. */ /**
* Mark that the navigation process within the explore navigation graph (initiated by
* [exploreNavigateTo]) was completed.
*/
fun finishExploreNavigation() { fun finishExploreNavigation() {
logD("Finishing explore navigation process") logD("Finishing explore navigation process")
_exploreNavigationItem.value = null _exploreNavigationItem.value = null
@ -106,17 +125,22 @@ class NavigationViewModel : ViewModel() {
} }
/** /**
* Represents the navigation options for the Main Fragment, which tends to be multiple layers above * Represents the possible actions within the main navigation graph. This can be used with
* normal fragments. This can be passed to [NavigationViewModel.mainNavigateTo] in order to * [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere
* facilitate navigation without workarounds.. * in the app, including outside the main navigation graph.
* @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class MainNavigationAction { sealed class MainNavigationAction {
/** Expand the playback panel. */ /** Expand the playback panel. */
object Expand : MainNavigationAction() object Expand : MainNavigationAction()
/** Collapse the playback panel. */ /** Collapse the playback bottom sheet. */
object Collapse : MainNavigationAction() object Collapse : MainNavigationAction()
/** Provide raw navigation directions. */ /**
* Navigate to the given [NavDirections].
* @param directions The [NavDirections] to navigate to. Assumed to be part of the main
* navigation graph.
*/
data class Directions(val directions: NavDirections) : MainNavigationAction() data class Directions(val directions: NavDirections) : MainNavigationAction()
} }

View file

@ -32,41 +32,51 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A dialog fragment enabling ViewBinding inflation and usage across the dialog fragment lifecycle. * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() { abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
private var _binding: VB? = null private var _binding: VB? = null
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>() private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/** Called during [onCreateDialog]. Dialog elements should be configured here. */ /**
* Configure the [AlertDialog.Builder] during [onCreateDialog].
* @param builder The [AlertDialog.Builder] to configure.
* @see onCreateDialog
*/
protected open fun onConfigDialog(builder: AlertDialog.Builder) {} protected open fun onConfigDialog(builder: AlertDialog.Builder) {}
/** /**
* Inflate the binding from the given [inflater]. This should usually be done by the binding * Inflate the [ViewBinding] during [onCreateView].
* implementation's inflate function. * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with.
* @return A new [ViewBinding] instance.
* @see onCreateView
*/ */
protected abstract fun onCreateBinding(inflater: LayoutInflater): VB protected abstract fun onCreateBinding(inflater: LayoutInflater): VB
/** /**
* Called during [onViewCreated] when the binding was successfully inflated and set as the view. * Configure the newly-inflated [ViewBinding] during [onViewCreated].
* This is where view setup should occur. * @param binding The [ViewBinding] to configure.
* @param savedInstanceState The previously saved state of the UI.
* @see onViewCreated
*/ */
protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {}
/** /**
* Called during [onDestroyView] when the binding should be destroyed and all callbacks or * Free memory held by the [ViewBinding] during [onDestroyView]
* leaking elements be released. * @param binding The [ViewBinding] to release.
* @see onDestroyView
*/ */
protected open fun onDestroyBinding(binding: VB) {} protected open fun onDestroyBinding(binding: VB) {}
/** Maybe get the binding. This will be null outside of the fragment view lifecycle. */ /** The [ViewBinding], or null if it has not been inflated yet. */
protected val binding: VB? protected val binding: VB?
get() = _binding get() = _binding
/** /**
* Get the binding under the assumption that the fragment has a view at this state in the * Get the [ViewBinding] under the assumption that it has been inflated.
* lifecycle. This will throw an exception if the fragment is not in a valid lifecycle. * @return The currently-inflated [ViewBinding].
* @throws IllegalStateException if the [ViewBinding] is not inflated.
*/ */
protected fun requireBinding(): VB { protected fun requireBinding(): VB {
return requireNotNull(_binding) { return requireNotNull(_binding) {
@ -74,7 +84,12 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
"right now, but instead it was ${lifecycle.currentState}" "right now, but instead it was ${lifecycle.currentState}"
} }
} }
// TODO: Phase this out
/**
* Delegate to automatically create and destroy an object derived from the [ViewBinding].
* TODO: Phase this out, it's really dumb
* @param create Block to create the object from the [ViewBinding].
*/
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> { fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
lifecycleObjects.add(LifecycleObject(null, create)) lifecycleObjects.add(LifecycleObject(null, create))
@ -90,35 +105,44 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
} }
} }
override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View = onCreateBinding(inflater).also { _binding = it }.root ) = onCreateBinding(inflater).also { _binding = it }.root
override fun onCreateDialog(savedInstanceState: Bundle?) = final override fun onCreateDialog(savedInstanceState: Bundle?) =
// Use a material-styled dialog for all implementations.
MaterialAlertDialogBuilder(requireActivity(), theme).run { MaterialAlertDialogBuilder(requireActivity(), theme).run {
onConfigDialog(this) onConfigDialog(this)
create() create()
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding) val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) } lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState) onBindingCreated(requireBinding(), savedInstanceState)
// Apply the newly-configured view to the dialog.
(requireDialog() as AlertDialog).setView(view) (requireDialog() as AlertDialog).setView(view)
logD("Fragment created") logD("Fragment created")
} }
override fun onDestroyView() { final override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding)) onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() } lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null _binding = null
logD("Fragment destroyed") logD("Fragment destroyed")
} }
/**
* Internal implementation of [lifecycleObject].
*/
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) { private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) { fun populate(binding: VB) {
data = create(binding) data = create(binding)

View file

@ -37,30 +37,36 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>() private var lifecycleObjects = mutableListOf<LifecycleObject<VB, *>>()
/** /**
* Inflate the binding from the given [inflater]. This should usually be done by the binding * Inflate the [ViewBinding] during [onCreateView].
* implementation's inflate function. * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with.
* @return A new [ViewBinding] instance.
* @see onCreateView
*/ */
protected abstract fun onCreateBinding(inflater: LayoutInflater): VB protected abstract fun onCreateBinding(inflater: LayoutInflater): VB
/** /**
* Called during [onViewCreated] when the binding was successfully inflated and set as the view. * Configure the newly-inflated [ViewBinding] during [onViewCreated].
* This is where view setup should occur. * @param binding The [ViewBinding] to configure.
* @param savedInstanceState The previously saved state of the UI.
* @see onViewCreated
*/ */
protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {}
/** /**
* Called during [onDestroyView] when the binding should be destroyed and all callbacks or * Free memory held by the [ViewBinding] during [onDestroyView]
* leaking elements be released. * @param binding The [ViewBinding] to release.
* @see onDestroyView
*/ */
protected open fun onDestroyBinding(binding: VB) {} protected open fun onDestroyBinding(binding: VB) {}
/** Maybe get the binding. This will be null outside of the fragment view lifecycle. */ /** The [ViewBinding], or null if it has not been inflated yet. */
protected val binding: VB? protected val binding: VB?
get() = _binding get() = _binding
/** /**
* Get the binding under the assumption that the fragment has a view at this state in the * Get the [ViewBinding] under the assumption that it has been inflated.
* lifecycle. This will throw an exception if the fragment is not in a valid lifecycle. * @return The currently-inflated [ViewBinding].
* @throws IllegalStateException if the [ViewBinding] is not inflated.
*/ */
protected fun requireBinding(): VB { protected fun requireBinding(): VB {
return requireNotNull(_binding) { return requireNotNull(_binding) {
@ -70,8 +76,9 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
} }
/** /**
* Shortcut to create a member bound to the lifecycle of this fragment. This is automatically * Delegate to automatically create and destroy an object derived from the [ViewBinding].
* populated in onBindingCreated, and destroyed in onDestroyBinding. * TODO: Phase this out, it's really dumb
* @param create Block to create the object from the [ViewBinding].
*/ */
fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> { fun <T> lifecycleObject(create: (VB) -> T): ReadOnlyProperty<Fragment, T> {
lifecycleObjects.add(LifecycleObject(null, create)) lifecycleObjects.add(LifecycleObject(null, create))
@ -88,28 +95,35 @@ abstract class ViewBindingFragment<VB : ViewBinding> : Fragment() {
} }
} }
override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View = onCreateBinding(inflater).also { _binding = it }.root ) = onCreateBinding(inflater).also { _binding = it }.root
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding) val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) } lifecycleObjects.forEach { it.populate(binding) }
onBindingCreated(binding, savedInstanceState) // Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
logD("Fragment created") logD("Fragment created")
} }
override fun onDestroyView() { final override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding)) onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() } lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null _binding = null
logD("Fragment destroyed") logD("Fragment destroyed")
} }
/**
* Internal implementation of [lifecycleObject].
*/
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) { private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) { fun populate(binding: VB) {
data = create(binding) data = create(binding)

View file

@ -22,7 +22,6 @@ import android.graphics.Bitmap
import android.os.Build import android.os.Build
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import kotlin.math.sqrt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.image.extractor.SquareFrameTransform
@ -36,9 +35,9 @@ import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A component that manages the state of all of Auxio's widgets. * A component that manages the "Now Playing" state.
* This is kept separate from the AppWidgetProviders themselves to prevent possible memory * This is kept separate from the [WidgetProvider] itself to prevent possible memory
* leaks and enable the main functionality to be extended to more widgets in the future. * leaks and enable extension to more widgets in the future.
* @param context [Context] required to manage AppWidgetProviders. * @param context [Context] required to manage AppWidgetProviders.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -56,7 +55,7 @@ class WidgetComponent(private val context: Context) :
/** /**
* Update [WidgetProvider] with the current playback state. * Update [WidgetProvider] with the current playback state.
*/ */
fun updateNowPlaying() { fun update() {
val song = playbackManager.song val song = playbackManager.song
if (song == null) { if (song == null) {
logD("No song, resetting widget") logD("No song, resetting widget")
@ -85,22 +84,16 @@ class WidgetComponent(private val context: Context) :
0 0
} }
val metrics = context.resources.displayMetrics
val sw = metrics.widthPixels
val sh = metrics.heightPixels
return if (cornerRadius > 0) { return if (cornerRadius > 0) {
// Reduce the size by 10x, not only to make 16dp-ish corners, but also // If rounded, educe the bitmap size further to obtain more pronounced
// to work around a bug in Android 13 where the bitmaps aren't pooled // rounded corners.
// properly, massively reducing the memory size we can work with.
builder builder
.size(computeWidgetImageSize(sw, sh, 10f)) .size(getSafeRemoteViewsImageSize(context, 10f))
.transformations( .transformations(
SquareFrameTransform.INSTANCE, SquareFrameTransform.INSTANCE,
RoundedCornersTransformation(cornerRadius.toFloat())) RoundedCornersTransformation(cornerRadius.toFloat()))
} else { } else {
// Divide by two to really make sure we aren't hitting the memory limit. builder.size(getSafeRemoteViewsImageSize(context))
builder.size(computeWidgetImageSize(sw, sh, 2f))
} }
} }
@ -111,18 +104,6 @@ class WidgetComponent(private val context: Context) :
}) })
} }
/**
* Get the recommended image size to load for use.
* @param sw The current screen width
* @param sh The current screen height
* @param modifier Modifier to reduce the image size.
* @return An image size that is guaranteed not to exceed the widget bitmap memory limit.
*/
private fun computeWidgetImageSize(sw: Int, sh: Int, modifier: Float) =
// Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
// that to obtain the image size.
sqrt((6f / 4f / modifier) * sw * sh).toInt()
/** /**
* Release this instance, preventing any further events from updating the widget instances. * Release this instance, preventing any further events from updating the widget instances.
*/ */
@ -137,15 +118,15 @@ class WidgetComponent(private val context: Context) :
// Hook all the major song-changing updates + the major player state updates // Hook all the major song-changing updates + the major player state updates
// to updating the "Now Playing" widget. // to updating the "Now Playing" widget.
override fun onIndexMoved(index: Int) = updateNowPlaying() override fun onIndexMoved(index: Int) = update()
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = updateNowPlaying() override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) = update()
override fun onStateChanged(state: InternalPlayer.State) = updateNowPlaying() override fun onStateChanged(state: InternalPlayer.State) = update()
override fun onShuffledChanged(isShuffled: Boolean) = updateNowPlaying() override fun onShuffledChanged(isShuffled: Boolean) = update()
override fun onRepeatChanged(repeatMode: RepeatMode) = updateNowPlaying() override fun onRepeatChanged(repeatMode: RepeatMode) = update()
override fun onSettingChanged(key: String) { override fun onSettingChanged(key: String) {
if (key == context.getString(R.string.set_key_cover_mode) || if (key == context.getString(R.string.set_key_cover_mode) ||
key == context.getString(R.string.set_key_round_mode)) { key == context.getString(R.string.set_key_round_mode)) {
updateNowPlaying() update()
} }
} }

View file

@ -12,6 +12,7 @@ import androidx.annotation.LayoutRes
import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.isLandscape
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent
import kotlin.math.sqrt
/** /**
* Create a [RemoteViews] instance with the specified layout and an automatic click handler * Create a [RemoteViews] instance with the specified layout and an automatic click handler
@ -26,6 +27,23 @@ fun newRemoteViews(context: Context, @LayoutRes layoutRes: Int): RemoteViews {
return views return views
} }
/**
* Get an image size guaranteed to not exceed the [RemoteViews] bitmap memory limit, assuming
* that there is only one image.
* @param context [Context] required to perform calculation.
* @param reduce Optional multiplier to reduce the image size. Recommended value is 2 to avoid
* device-specific variations in memory limit.
* @return The dimension of a bitmap that can be safely used in [RemoteViews].
*/
fun getSafeRemoteViewsImageSize(context: Context, reduce: Float = 2f): Int {
val metrics = context.resources.displayMetrics
val sw = metrics.widthPixels
val sh = metrics.heightPixels
// Maximum size is 1/3 total screen area * 4 bytes per pixel. Reverse
// that to obtain the image size.
return sqrt((6f / 4f / reduce) * sw * sh).toInt()
}
/** /**
* Set the background resource of a [RemoteViews] View. * Set the background resource of a [RemoteViews] View.
* @param viewId The ID of the view to update. * @param viewId The ID of the view to update.

View file

@ -12,7 +12,7 @@
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/nav_explore" app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />

View file

@ -13,7 +13,7 @@
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior"
app:navGraph="@navigation/nav_explore" app:navGraph="@navigation/nav_explore"
tools:layout="@layout/fragment_home" /> tools:layout="@layout/fragment_home" />
@ -35,7 +35,7 @@
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment" android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="org.oxycblt.auxio.shared.BottomSheetContentBehavior" /> app:layout_behavior="org.oxycblt.auxio.playback.ui.BottomSheetContentBehavior" />
<LinearLayout <LinearLayout
android:id="@+id/queue_sheet" android:id="@+id/queue_sheet"