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.IntegerTable
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.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.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
ServiceNotification(context, INDEXER_CHANNEL) {
ForegroundServiceNotification(context, INDEXER_CHANNEL) {
private var lastUpdateTime = -1L
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.
* @author Alexander Capehart (OxygenCobalt)
*/
class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) {
class ObservingNotification(context: Context) : ForegroundServiceNotification(context, INDEXER_CHANNEL) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -111,5 +111,5 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val INDEXER_CHANNEL =
ServiceNotification.ChannelInfo(
ForegroundServiceNotification.ChannelInfo(
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 com.google.android.material.shape.MaterialShapeDrawable
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.getDimen
@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.getDimen
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioBottomSheetBehavior<V>(context, attributeSet) {
BaseBottomSheetBehavior<V>(context, attributeSet) {
val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface)

View file

@ -24,7 +24,7 @@ import android.view.WindowInsets
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable
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.getDimen
import org.oxycblt.auxio.util.getDimenPixels
@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioBottomSheetBehavior<V>(context, attributeSet) {
BaseBottomSheetBehavior<V>(context, attributeSet) {
private var barHeight = 0
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.R
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.newMainPendingIntent
@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent
*/
@SuppressLint("RestrictedApi")
class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) :
ServiceNotification(context, CHANNEL_INFO) {
ForegroundServiceNotification(context, CHANNEL_INFO) {
init {
setSmallIcon(R.drawable.ic_auxio_24)
setCategory(NotificationCompat.CATEGORY_TRANSPORT)

View file

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

View file

@ -15,7 +15,7 @@
* 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.util.AttributeSet

View file

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

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.search
import android.app.Application
import androidx.annotation.IdRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.text.Normalizer
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?) {
// 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.
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.addAll(sort.artists(artists))
results.addAll(sort.artists(it))
}
}
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.addAll(sort.albums(albums))
results.addAll(sort.albums(it))
}
}
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.addAll(sort.genres(genres))
results.addAll(sort.genres(it))
}
}
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.addAll(sort.songs(songs))
results.addAll(sort.songs(it))
}
}
@ -138,26 +139,18 @@ class SearchViewModel(application: Application) :
return results
}
private fun List<Song>.filterSongsBy(value: String) =
searchListImpl(value) {
// Include both the sort name (can have normalized versions of titles) and
// file name (helpful for poorly tagged songs) to the filtering.
it.rawSortName?.contains(value, ignoreCase = true) == true ||
it.path.name.contains(value)
}
private fun List<Album>.filterAlbumsBy(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<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 {
/**
* Search a given [Music] list.
* @param query The query to search for. The routine will compare this query to the names
* of each object in the list and
* @param fallback Additional comparison code to run if the item does not match the query
* initially. This can be used to compare against additional attributes to improve search
* result quality.
*/
private inline fun <T : Music> List<T>.searchListImpl(
query: String,
fallback: (String, T) -> Boolean = { _, _ -> false }
) = filter {
// See if the plain resolved name matches the query. This works for most situations.
val name = it.resolveName(context)
if (name.contains(query, ignoreCase = true)) {
@ -180,7 +173,7 @@ class SearchViewModel(application: Application) :
return@filter true
}
fallback(it)
fallback(query, it)
}
.ifEmpty { null }

View file

@ -31,10 +31,11 @@ import com.google.android.material.appbar.AppBarLayout
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
/**
* An [AppBarLayout] that fixes several bugs with the default implementation where the lifted state
* will not properly respond to RecyclerView events.
* An [AppBarLayout] that resolves two issues with the default implementation:
* 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.
*
* 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) :
AppBarLayout(context, attrs, defStyleAttr) {
private var scrollingChild: View? = null
private val tConsumed = IntArray(2)
private val tConsumed = IntArray(2)
private val onPreDraw =
ViewTreeObserver.OnPreDrawListener {
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?) {
setExpanded(true)
@ -82,7 +86,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) {
super.setLiftOnScrollTargetViewId(liftOnScrollTargetViewId)
// Sometimes we dynamically set the scrolling child [such as in HomeFragment], so clear it
// and re-draw when that occurs.
scrollingChild = null
@ -103,26 +106,40 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
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) :
OnOffsetChangedListener {
private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() + 600)
private var lastVerticalOffset: Int? = null
private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() +
APP_BAR_LAYOUT_MAX_OFFSET_ANIMATION_DURATION)
private var currentVerticalOffset: Int? = null
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
if (verticalOffset == 0 ||
AnimationUtils.currentAnimationTimeMillis() > offsetAnimationMaxEndTime) {
// AppBarLayout crashes with IndexOutOfBoundsException when a non-last listener
// removes
// itself, so we have to do the removal asynchronously.
appBarLayout.postOnAnimation { appBarLayout.removeOnOffsetChangedListener(this) }
// removes itself, so we have to do the removal asynchronously.
appBarLayout.postOnAnimation {
appBarLayout.removeOnOffsetChangedListener(this) }
}
val lastVerticalOffset = lastVerticalOffset
this.lastVerticalOffset = verticalOffset
if (lastVerticalOffset != null) {
recycler.scrollBy(0, verticalOffset - lastVerticalOffset)
// If possible, scroll by the offset delta between this update and the last update.
val oldVerticalOffset = currentVerticalOffset
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
/**
* 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)
*
* TODO: Merge with unified service when done.
*/
class ForegroundManager(private val service: Service) {
private var isForeground = false
/**
* Release this instance.
*/
fun release() {
tryStopForeground()
}
/**
* Try to enter a foreground state. Returns false if already in foreground, returns true if
* state was entered.
* Try to enter a foreground state.
* @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) {
// Nothing to do.
return false
}
logD("Starting foreground state")
service.startForeground(notification.code, notification.build())
isForeground = true
return true
}
/**
* Try to stop a foreground state. Returns false if already in backend, returns true if state
* was stopped.
* Try to exit a foreground state. Will remove the foreground notification.
* @return true if the state was changed, false otherwise
* @see Service.stopForeground
*/
fun tryStopForeground(): Boolean {
if (!isForeground) {
// Nothing to do.
return false
}
logD("Stopping foreground state")
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
return true
}
}

View file

@ -24,15 +24,17 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
/**
* Wrapper around [NotificationCompat.Builder] that automates parts of the notification setup, under
* the assumption that the notification will be used in a service.
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ServiceNotification(context: Context, info: ChannelInfo) :
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
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 =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
@ -40,18 +42,29 @@ abstract class ServiceNotification(context: Context, info: ChannelInfo) :
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
@Suppress("MissingPermission")
/**
* Post this notification using [NotificationManagerCompat].
*/
fun post() {
// This is safe to call without the POST_NOTIFICATIONS permission, as it's a foreground
// notification.
@Suppress("MissingPermission")
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)
}

View file

@ -26,35 +26,40 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.logD
/**
* A ViewModel that handles complicated navigation situations.
* @author Alexander Capehart (OxygenCobalt)
* A [ViewModel] that handles complicated navigation functionality.
*/
class NavigationViewModel : ViewModel() {
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?>
get() = _mainNavigationAction
private val _exploreNavigationItem = MutableStateFlow<Music?>(null)
/**
* Flag for navigation within the explore fragments. Observe this to coordinate navigation to an
* item's UI.
* Flag for navigation within the explore navigation graph. Observe this to coordinate
* navigation to a specific [Music] item.
*/
val exploreNavigationItem: StateFlow<Music?>
get() = _exploreNavigationItem
private val _exploreNavigationArtists = MutableStateFlow<List<Artist>?>(null)
/**
* Flag for navigation within the explore fragments. In this case, it involves an ambiguous list
* of artist choices.
* Variation of [exploreNavigationItem] for situations where the choice of [Artist]
* 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>?>
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) {
if (_mainNavigationAction.value != null) {
logD("Already navigating, not doing main action")
@ -65,13 +70,20 @@ class NavigationViewModel : ViewModel() {
_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() {
logD("Finishing main navigation process")
_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) {
if (_exploreNavigationItem.value != null) {
logD("Already navigating, not doing explore action")
@ -82,22 +94,29 @@ class NavigationViewModel : ViewModel() {
_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) {
logD("Already navigating, not doing explore action")
return
}
if (items.size == 1) {
exploreNavigateTo(items[0])
if (artists.size == 1) {
exploreNavigateTo(artists[0])
} else {
logD("Navigating to a choice of ${items.map { it.rawName }}")
_exploreNavigationArtists.value = items
logD("Navigating to a choice of ${artists.map { it.rawName }}")
_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() {
logD("Finishing explore navigation process")
_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
* normal fragments. This can be passed to [NavigationViewModel.mainNavigateTo] in order to
* facilitate navigation without workarounds..
* Represents the possible actions within the main navigation graph. This can be used with
* [NavigationViewModel] to initiate navigation in the main navigation graph from anywhere
* in the app, including outside the main navigation graph.
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class MainNavigationAction {
/** Expand the playback panel. */
object Expand : MainNavigationAction()
/** Collapse the playback panel. */
/** Collapse the playback bottom sheet. */
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()
}

View file

@ -32,41 +32,51 @@ import org.oxycblt.auxio.util.logD
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)
*/
abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
private var _binding: VB? = null
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) {}
/**
* Inflate the binding from the given [inflater]. This should usually be done by the binding
* implementation's inflate function.
* Inflate the [ViewBinding] during [onCreateView].
* @param inflater The [LayoutInflater] to inflate the [ViewBinding] with.
* @return A new [ViewBinding] instance.
* @see onCreateView
*/
protected abstract fun onCreateBinding(inflater: LayoutInflater): VB
/**
* Called during [onViewCreated] when the binding was successfully inflated and set as the view.
* This is where view setup should occur.
* Configure the newly-inflated [ViewBinding] during [onViewCreated].
* @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?) {}
/**
* Called during [onDestroyView] when the binding should be destroyed and all callbacks or
* leaking elements be released.
* Free memory held by the [ViewBinding] during [onDestroyView]
* @param binding The [ViewBinding] to release.
* @see onDestroyView
*/
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?
get() = _binding
/**
* Get the binding under the assumption that the fragment has a view at this state in the
* lifecycle. This will throw an exception if the fragment is not in a valid lifecycle.
* Get the [ViewBinding] under the assumption that it has been inflated.
* @return The currently-inflated [ViewBinding].
* @throws IllegalStateException if the [ViewBinding] is not inflated.
*/
protected fun requireBinding(): VB {
return requireNotNull(_binding) {
@ -74,7 +84,12 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
"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> {
lifecycleObjects.add(LifecycleObject(null, create))
@ -90,35 +105,44 @@ abstract class ViewBindingDialogFragment<VB : ViewBinding> : DialogFragment() {
}
}
override fun onCreateView(
final override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
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 {
onConfigDialog(this)
create()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = unlikelyToBeNull(_binding)
// Populate lifecycle-dependent objects
lifecycleObjects.forEach { it.populate(binding) }
// Configure binding
onBindingCreated(requireBinding(), savedInstanceState)
// Apply the newly-configured view to the dialog.
(requireDialog() as AlertDialog).setView(view)
logD("Fragment created")
}
override fun onDestroyView() {
final override fun onDestroyView() {
super.onDestroyView()
onDestroyBinding(unlikelyToBeNull(_binding))
// Clear the lifecycle-dependent objects
lifecycleObjects.forEach { it.clear() }
// Clear binding
_binding = null
logD("Fragment destroyed")
}
/**
* Internal implementation of [lifecycleObject].
*/
private data class LifecycleObject<VB, T>(var data: T?, val create: (VB) -> T) {
fun populate(binding: VB) {
data = create(binding)

View file

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

View file

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

View file

@ -12,7 +12,7 @@
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="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"
tools:layout="@layout/fragment_home" />

View file

@ -13,7 +13,7 @@
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="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"
tools:layout="@layout/fragment_home" />
@ -35,7 +35,7 @@
android:name="org.oxycblt.auxio.playback.PlaybackPanelFragment"
android:layout_width="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
android:id="@+id/queue_sheet"