shared: redocument
Redocument the shared module (previously ui).
This commit is contained in:
parent
b9210ecfe0
commit
18ba845302
19 changed files with 296 additions and 191 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -451,7 +451,7 @@ class PlaybackService :
|
|||
playbackManager.changePlaying(false)
|
||||
stopAndSave()
|
||||
}
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.updateNowPlaying()
|
||||
WidgetProvider.ACTION_WIDGET_UPDATE -> widgetComponent.update()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue