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.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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue