all: document custom stuff
Document and clean up PlaybackBarLayout and the fast scroll views to an extent.
This commit is contained in:
parent
8b8d36cf22
commit
1b79eb11e0
11 changed files with 342 additions and 267 deletions
|
@ -83,7 +83,7 @@ class ExcludedDialog : LifecycleDialog() {
|
|||
}
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
|
||||
if (excludedModel.isModified()) {
|
||||
if (excludedModel.isModified) {
|
||||
saveAndRestart()
|
||||
} else {
|
||||
dismiss()
|
||||
|
|
|
@ -33,13 +33,17 @@ import kotlinx.coroutines.withContext
|
|||
* of paths. Use [Factory] to instantiate this.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExcludedViewModel(context: Context) : ViewModel() {
|
||||
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
|
||||
private val mPaths = MutableLiveData(mutableListOf<String>())
|
||||
val paths: LiveData<MutableList<String>> get() = mPaths
|
||||
|
||||
private val excludedDatabase = ExcludedDatabase.getInstance(context)
|
||||
private var dbPaths = listOf<String>()
|
||||
|
||||
/**
|
||||
* Check if changes have been made to the ViewModel's paths.
|
||||
*/
|
||||
val isModified: Boolean get() = dbPaths != paths.value
|
||||
|
||||
init {
|
||||
loadDatabasePaths()
|
||||
}
|
||||
|
@ -89,11 +93,6 @@ class ExcludedViewModel(context: Context) : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if changes have been made to the ViewModel's paths.
|
||||
*/
|
||||
fun isModified() = dbPaths != paths.value
|
||||
|
||||
class Factory(private val context: Context) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) {
|
||||
|
@ -101,7 +100,7 @@ class ExcludedViewModel(context: Context) : ViewModel() {
|
|||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ExcludedViewModel(context) as T
|
||||
return ExcludedViewModel(ExcludedDatabase.getInstance(context)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,112 +46,114 @@ import kotlin.math.sqrt
|
|||
* !!! MODIFICATIONS !!!:
|
||||
* - Use modified Auxio resources instead of AFS resources
|
||||
* - Variable names are no longer prefixed with m
|
||||
* - Suppressed deprecation warning when dealing with convexness
|
||||
* - Made path management compat-friendly
|
||||
*/
|
||||
class Md2PopupBackground(context: Context) : Drawable() {
|
||||
private val paint: Paint = Paint()
|
||||
private val paddingStart: Int
|
||||
private val paddingEnd: Int
|
||||
class FastScrollPopupDrawable(context: Context) : Drawable() {
|
||||
private val paint: Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = R.attr.colorControlActivated.resolveAttr(context)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private val tempMatrix = Matrix()
|
||||
private val matrix = Matrix()
|
||||
|
||||
private val paddingStart = context.resources.getDimensionPixelOffset(R.dimen.spacing_medium)
|
||||
private val paddingEnd = context.resources.getDimensionPixelOffset(R.dimen.popup_padding_end)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
updatePath()
|
||||
}
|
||||
|
||||
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
|
||||
updatePath()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {}
|
||||
override fun isAutoMirrored(): Boolean {
|
||||
return true
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOutline(outline: Outline) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
|
||||
|
||||
private fun needMirroring(): Boolean {
|
||||
return DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
|
||||
}
|
||||
// Paths don't need to be convex on android Q, but the API was mislabeled and so
|
||||
// we still have to use this method.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
|
||||
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSLUCENT
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
updatePath()
|
||||
}
|
||||
|
||||
private fun updatePath() {
|
||||
path.reset()
|
||||
val bounds = bounds
|
||||
var width = bounds.width().toFloat()
|
||||
val height = bounds.height().toFloat()
|
||||
val r = height / 2
|
||||
val sqrt2 = sqrt(2.0).toFloat()
|
||||
// Ensure we are convex.
|
||||
width = (r + sqrt2 * r).coerceAtLeast(width)
|
||||
pathArcTo(path, r, r, r, 90f, 180f)
|
||||
val o1X = width - sqrt2 * r
|
||||
pathArcTo(path, o1X, r, r, -90f, 45f)
|
||||
val r2 = r / 5
|
||||
val o2X = width - sqrt2 * r2
|
||||
pathArcTo(path, o2X, r, r2, -45f, 90f)
|
||||
pathArcTo(path, o1X, r, r, 45f, 45f)
|
||||
path.close()
|
||||
if (needMirroring()) {
|
||||
tempMatrix.setScale(-1f, 1f, width / 2, 0f)
|
||||
} else {
|
||||
tempMatrix.reset()
|
||||
else -> if (!path.isConvex) {
|
||||
// The outline path must be convex before Q, but we may run into floating point
|
||||
// error caused by calculations involving sqrt(2) or OEM implementation differences,
|
||||
// so in this case we just omit the shadow instead of crashing.
|
||||
super.getOutline(outline)
|
||||
}
|
||||
}
|
||||
tempMatrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
|
||||
path.transform(tempMatrix)
|
||||
}
|
||||
|
||||
override fun getPadding(padding: Rect): Boolean {
|
||||
if (needMirroring()) {
|
||||
if (isRtl) {
|
||||
padding[paddingEnd, 0, paddingStart] = 0
|
||||
} else {
|
||||
padding[paddingStart, 0, paddingEnd] = 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOutline(outline: Outline) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !path.isConvex) {
|
||||
// The outline path must be convex before Q, but we may run into floating point error
|
||||
// caused by calculation involving sqrt(2) or OEM implementation difference, so in this
|
||||
// case we just omit the shadow instead of crashing.
|
||||
super.getOutline(outline)
|
||||
return
|
||||
override fun isAutoMirrored(): Boolean = true
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {}
|
||||
|
||||
private fun updatePath() {
|
||||
path.reset()
|
||||
|
||||
var width = bounds.width().toFloat()
|
||||
val height = bounds.height().toFloat()
|
||||
val r = height / 2
|
||||
val sqrt2 = sqrt(2.0).toFloat()
|
||||
|
||||
// Ensure we are convex.
|
||||
width = (r + sqrt2 * r).coerceAtLeast(width)
|
||||
pathArcTo(path, r, r, r, 90f, 180f)
|
||||
|
||||
val o1X = width - sqrt2 * r
|
||||
pathArcTo(path, o1X, r, r, -90f, 45f)
|
||||
|
||||
val r2 = r / 5
|
||||
val o2X = width - sqrt2 * r2
|
||||
pathArcTo(path, o2X, r, r2, -45f, 90f)
|
||||
pathArcTo(path, o1X, r, r, 45f, 45f)
|
||||
|
||||
path.close()
|
||||
|
||||
if (isRtl) {
|
||||
matrix.setScale(-1f, 1f, width / 2, 0f)
|
||||
} else {
|
||||
matrix.reset()
|
||||
}
|
||||
|
||||
outline.setConvexPath(path)
|
||||
matrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
|
||||
|
||||
path.transform(matrix)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun pathArcTo(
|
||||
path: Path,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
radius: Float,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float
|
||||
) {
|
||||
path.arcTo(
|
||||
centerX - radius, centerY - radius, centerX + radius, centerY + radius,
|
||||
startAngle, sweepAngle, false
|
||||
)
|
||||
}
|
||||
private fun pathArcTo(
|
||||
path: Path,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
radius: Float,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float
|
||||
) {
|
||||
path.arcTo(
|
||||
centerX - radius, centerY - radius, centerX + radius, centerY + radius,
|
||||
startAngle, sweepAngle, false
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
paint.isAntiAlias = true
|
||||
paint.color = R.attr.colorControlActivated.resolveAttr(context)
|
||||
paint.style = Paint.Style.FILL
|
||||
val resources = context.resources
|
||||
paddingStart = resources.getDimensionPixelOffset(R.dimen.spacing_medium)
|
||||
paddingEnd = resources.getDimensionPixelOffset(R.dimen.popup_padding_end)
|
||||
}
|
||||
private val isRtl: Boolean get() =
|
||||
DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
|
||||
}
|
|
@ -142,7 +142,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
|
||||
setLayoutParams(layoutParams)
|
||||
|
||||
background = Md2PopupBackground(context)
|
||||
background = FastScrollPopupDrawable(context)
|
||||
elevation = resources.getDimensionPixelOffset(R.dimen.elevation_normal).toFloat()
|
||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||
gravity = Gravity.CENTER
|
||||
|
@ -335,7 +335,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
getDecoratedBoundsWithMargins(getChildAt(0), childRect)
|
||||
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - childRect.top
|
||||
|
||||
// Finally, we can calculate the thumb position, which is just:
|
||||
// Then calculate the thumb position, which is just:
|
||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
|
||||
}
|
||||
|
@ -396,25 +396,14 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
private fun isInViewTouchTarget(view: View, x: Float, y: Float): Boolean {
|
||||
val scrollX = scrollX
|
||||
val scrollY = scrollY
|
||||
return (
|
||||
isInTouchTarget(
|
||||
x, view.left - scrollX, view.right - scrollX, 0,
|
||||
width
|
||||
) &&
|
||||
isInTouchTarget(
|
||||
y, view.top - scrollY, view.bottom - scrollY, 0,
|
||||
height
|
||||
)
|
||||
)
|
||||
return isInTouchTarget(x, view.left - scrollX, view.right - scrollX, width) &&
|
||||
isInTouchTarget(y, view.top - scrollY, view.bottom - scrollY, height)
|
||||
}
|
||||
|
||||
private fun isInTouchTarget(
|
||||
position: Float,
|
||||
viewStart: Int,
|
||||
viewEnd: Int,
|
||||
parentStart: Int,
|
||||
parentEnd: Int
|
||||
): Boolean {
|
||||
val viewSize = viewEnd - viewStart
|
||||
|
@ -424,16 +413,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
|
||||
if (touchTargetStart < parentStart) {
|
||||
touchTargetStart = parentStart
|
||||
|
||||
if (touchTargetStart < 0) {
|
||||
touchTargetStart = 0
|
||||
}
|
||||
|
||||
var touchTargetEnd = touchTargetStart + minTouchTargetSize
|
||||
if (touchTargetEnd > parentEnd) {
|
||||
touchTargetEnd = parentEnd
|
||||
touchTargetStart = touchTargetEnd - minTouchTargetSize
|
||||
if (touchTargetStart < parentStart) {
|
||||
touchTargetStart = parentStart
|
||||
|
||||
if (touchTargetStart < 0) {
|
||||
touchTargetStart = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -441,10 +432,11 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
private fun scrollToThumbOffset(thumbOffset: Int) {
|
||||
var newThumbOffset = thumbOffset
|
||||
newThumbOffset = MathUtils.clamp(newThumbOffset, 0, thumbOffsetRange)
|
||||
var scrollOffset = (scrollOffsetRange.toLong() * newThumbOffset / thumbOffsetRange).toInt()
|
||||
scrollOffset -= paddingTop
|
||||
val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange)
|
||||
|
||||
val scrollOffset = (
|
||||
scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange
|
||||
).toInt() - paddingTop
|
||||
|
||||
scrollTo(scrollOffset)
|
||||
}
|
||||
|
@ -454,6 +446,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
|
||||
val trueOffset = offset - paddingTop
|
||||
val itemHeight = itemHeight
|
||||
|
||||
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
|
||||
val firstItemTop = firstItemPosition * itemHeight - trueOffset
|
||||
|
||||
|
@ -557,32 +550,11 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
private val isRtl: Boolean
|
||||
get() = layoutDirection == LAYOUT_DIRECTION_RTL
|
||||
|
||||
private val scrollOffsetRange: Int
|
||||
get() = scrollRange - height
|
||||
|
||||
private val thumbOffsetRange: Int
|
||||
get() {
|
||||
return height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight
|
||||
}
|
||||
|
||||
private val itemCount: Int
|
||||
get() = when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
|
||||
is LinearLayoutManager -> mgr.itemCount
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private val itemHeight: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemView = getChildAt(0)
|
||||
getDecoratedBoundsWithMargins(itemView, childRect)
|
||||
return childRect.height()
|
||||
}
|
||||
|
||||
private val scrollRange: Int
|
||||
get() {
|
||||
val itemCount = itemCount
|
||||
|
@ -600,6 +572,9 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val scrollOffsetRange: Int
|
||||
get() = scrollRange - height
|
||||
|
||||
private val firstAdapterPos: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
|
@ -615,6 +590,24 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val itemHeight: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemView = getChildAt(0)
|
||||
getDecoratedBoundsWithMargins(itemView, childRect)
|
||||
return childRect.height()
|
||||
}
|
||||
|
||||
private val itemCount: Int
|
||||
get() = when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
|
||||
is LinearLayoutManager -> mgr.itemCount
|
||||
else -> 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANIM_MILLIS = 150L
|
||||
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
|
|
|
@ -44,10 +44,10 @@ sealed class Music : BaseModel() {
|
|||
}
|
||||
|
||||
/**
|
||||
* [BaseModel] variant that denotes that this object is a parent of other data objects, such
|
||||
* [Music] variant that denotes that this object is a parent of other data objects, such
|
||||
* as an [Album] or [Artist]
|
||||
* @property resolvedName A name resolved from it's raw form to a form suitable to be shown in
|
||||
* a ui. Ex. unknown would become Unknown Artist, (124) would become its proper genre name, etc.
|
||||
* a ui. Ex. "unknown" would become Unknown Artist, (124) would become its proper genre name, etc.
|
||||
*/
|
||||
sealed class MusicParent : Music() {
|
||||
abstract val resolvedName: String
|
||||
|
@ -200,10 +200,55 @@ data class Genre(
|
|||
override val hash = name.hashCode().toLong()
|
||||
}
|
||||
|
||||
/**
|
||||
* A data object used solely for the "Header" UI element.
|
||||
* @see HeaderString
|
||||
*/
|
||||
data class Header(
|
||||
override val id: Long,
|
||||
val string: HeaderString
|
||||
) : BaseModel()
|
||||
|
||||
/**
|
||||
* A data object used for an action header. Like [Header], but with a button.
|
||||
* @see Header
|
||||
*/
|
||||
data class ActionHeader(
|
||||
override val id: Long,
|
||||
val string: HeaderString,
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val desc: Int,
|
||||
val onClick: (View) -> Unit,
|
||||
) : BaseModel() {
|
||||
// JVM can't into comparing lambdas, so we override equals/hashCode and exclude them.
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ActionHeader) return false
|
||||
|
||||
if (id != other.id) return false
|
||||
if (string != other.string) return false
|
||||
if (icon != other.icon) return false
|
||||
if (desc != other.desc) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + string.hashCode()
|
||||
result = 31 * result + icon
|
||||
result = 31 * result + desc
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The string used for a header instance. This class is a bit complex, mostly because it revolves
|
||||
* around passing string resources that are then resolved by the view instead of passing a context
|
||||
* directly.
|
||||
* around passing string resources that are then resolved by the view. This is because ViewModel
|
||||
* instance should preferably not have access to a Context but should still generate data,
|
||||
* which at times can include [Header] instances that require string resources.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
sealed class HeaderString {
|
||||
|
@ -272,47 +317,3 @@ sealed class HeaderString {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A data object used solely for the "Header" UI element.
|
||||
* @see HeaderString
|
||||
*/
|
||||
data class Header(
|
||||
override val id: Long,
|
||||
val string: HeaderString
|
||||
) : BaseModel()
|
||||
|
||||
/**
|
||||
* A data object used for an action header. Like [Header], but with a button.
|
||||
* @see HeaderString
|
||||
*/
|
||||
data class ActionHeader(
|
||||
override val id: Long,
|
||||
val string: HeaderString,
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val desc: Int,
|
||||
val onClick: (View) -> Unit,
|
||||
) : BaseModel() {
|
||||
// JVM can't into comparing lambdas, so we override equals/hashCode and exclude them.
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ActionHeader) return false
|
||||
|
||||
if (id != other.id) return false
|
||||
if (string != other.string) return false
|
||||
if (icon != other.icon) return false
|
||||
if (desc != other.desc) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + string.hashCode()
|
||||
result = 31 * result + icon
|
||||
result = 31 * result + desc
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logD
|
|||
* have to query for each genre, query all the songs in each genre, and then iterate through those
|
||||
* songs to link every song with their genre. This is not documented anywhere, and the
|
||||
* O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's
|
||||
* loading times. At no point have the devs considered that this column is absolutely busted, and
|
||||
* loading times. At no point have the devs considered that this column is absolutely insane, and
|
||||
* instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their
|
||||
* own Google Play Music, and we all know how great that worked out!
|
||||
*
|
||||
|
@ -221,7 +221,7 @@ class MusicLoader(private val context: Context) {
|
|||
}
|
||||
|
||||
songs = songs.distinctBy {
|
||||
it.name to it.albumId to it.artistName to it.track to it.duration
|
||||
it.name to it.albumName to it.artistName to it.track to it.duration
|
||||
}.toMutableList()
|
||||
|
||||
logD("Song search finished with ${songs.size} found")
|
||||
|
|
|
@ -22,7 +22,9 @@ import android.content.Context
|
|||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.RippleDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.WindowInsets
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ViewCompactPlaybackBinding
|
||||
|
@ -30,6 +32,7 @@ import org.oxycblt.auxio.music.Song
|
|||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.resolveAttr
|
||||
import org.oxycblt.auxio.util.resolveDrawable
|
||||
import org.oxycblt.auxio.util.systemBarsCompat
|
||||
|
||||
/**
|
||||
* A view displaying the playback state in a compact manner. This is only meant to be used
|
||||
|
@ -82,6 +85,11 @@ class CompactPlaybackView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
updatePadding(bottom = insets.systemBarsCompat.bottom)
|
||||
return insets
|
||||
}
|
||||
|
||||
fun setSong(song: Song) {
|
||||
binding.song = song
|
||||
binding.executePendingBindings()
|
||||
|
|
|
@ -28,7 +28,6 @@ import android.view.WindowInsets
|
|||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.customview.widget.ViewDragHelper
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.systemBarsCompat
|
||||
|
@ -36,11 +35,12 @@ import org.oxycblt.auxio.util.systemBarsCompat
|
|||
/**
|
||||
* A layout that manages the bottom playback bar while still enabling edge-to-edge to work
|
||||
* properly. The mechanism is mostly inspired by Material Files' PersistentBarLayout, however
|
||||
* this class was primarily written by me and I plan to expand this layout to become part of
|
||||
* the playback navigation process.
|
||||
* this class was primarily written by me.
|
||||
*
|
||||
* TODO: Explain how this thing works so that others can be spared the pain of deciphering
|
||||
* this custom viewgroup
|
||||
* TODO: Add a swipe-up behavior a la Phonograph. I think that would improve UX.
|
||||
* TODO: Leverage this layout to make more tablet-friendly UIs
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class PlaybackBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
|
@ -49,12 +49,14 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
@StyleRes defStyleRes: Int = 0
|
||||
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
|
||||
private val playbackView = CompactPlaybackView(context)
|
||||
private var barDragHelper = ViewDragHelper.create(this, ViewDragCallback())
|
||||
private var barDragHelper = ViewDragHelper.create(this, BarDragCallback())
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
init {
|
||||
addView(playbackView)
|
||||
|
||||
// playbackView is special as it's the view we treat as a bottom bar.
|
||||
// Mark it as such.
|
||||
(playbackView.layoutParams as LayoutParams).apply {
|
||||
width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
|
@ -70,16 +72,21 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
|
||||
setMeasuredDimension(widthSize, heightSize)
|
||||
|
||||
// Measure the bar view so that it fills the whole screen and takes up the bottom views.
|
||||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
|
||||
val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width)
|
||||
val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height)
|
||||
playbackView.measure(barWidthSpec, barHeightSpec)
|
||||
|
||||
updateWindowInsets()
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the content views in this layout. This is done separately as at times we want
|
||||
* to relayout the content views but not relayout the bar view.
|
||||
*/
|
||||
private fun measureContent() {
|
||||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
val barHeightAdjusted = (playbackView.measuredHeight * barParams.offset).toInt()
|
||||
|
@ -103,24 +110,22 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
val barHeight = playbackView.measuredHeight
|
||||
val barHeightAdjusted = (barHeight * (playbackView.layoutParams as LayoutParams).offset).toInt()
|
||||
val barParams = (playbackView.layoutParams as LayoutParams)
|
||||
val barHeightAdjusted = (barHeight * barParams.offset).toInt()
|
||||
|
||||
for (child in children) {
|
||||
if (child.visibility == View.GONE) continue
|
||||
|
||||
val childParams = child.layoutParams as LayoutParams
|
||||
|
||||
if (childParams.isBar) {
|
||||
child.layout(
|
||||
0, height - barHeightAdjusted,
|
||||
width, height + (barHeight - barHeightAdjusted)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Again, lay out our view like we measured it.
|
||||
playbackView.layout(
|
||||
0, height - barHeightAdjusted,
|
||||
width, height + (barHeight - barHeightAdjusted)
|
||||
)
|
||||
|
||||
layoutContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout the content views in this layout. This is done separately as at times we want
|
||||
* to relayout the content views but not relayout the bar view.
|
||||
*/
|
||||
private fun layoutContent() {
|
||||
for (child in children) {
|
||||
val childParams = child.layoutParams as LayoutParams
|
||||
|
@ -132,15 +137,79 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
playbackView.updatePadding(bottom = insets.systemBarsCompat.bottom)
|
||||
// Applying window insets is the real special sauce of this layout. The problem with
|
||||
// having a bottom bar is that if you support edge-to-edge, applying insets to views
|
||||
// will result in spacing being incorrect whenever the bar is shown. If you cleverly
|
||||
// modify the insets however, you can make all content views remove their spacing as
|
||||
// the bar enters. This function itself is unimportant, so you should probably take
|
||||
// a look at applyContentWindowInsets and adjustInsets instead.
|
||||
playbackView.onApplyWindowInsets(insets)
|
||||
|
||||
lastInsets = insets
|
||||
updateWindowInsets()
|
||||
applyContentWindowInsets()
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply window insets to the content views in this layouts. This is done separately as at
|
||||
* times we want to relayout the content views but not relayout the bar view.
|
||||
*/
|
||||
private fun applyContentWindowInsets() {
|
||||
val insets = lastInsets
|
||||
|
||||
if (insets != null) {
|
||||
val adjustedInsets = adjustInsets(insets)
|
||||
|
||||
for (child in children) {
|
||||
val childParams = child.layoutParams as LayoutParams
|
||||
|
||||
if (!childParams.isBar) {
|
||||
child.dispatchApplyWindowInsets(adjustedInsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust window insets to line up with the playback bar
|
||||
*/
|
||||
private fun adjustInsets(insets: WindowInsets): WindowInsets {
|
||||
// Find how much space the bar is consuming right now. We use this to modify
|
||||
// the bottom window inset so that the spacing checks out, 0 if the bar is fully
|
||||
// shown and the original value if the bar is hidden.
|
||||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
val barConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt()
|
||||
|
||||
val bars = insets.systemBarsCompat
|
||||
val adjustedBottomInset = (bars.bottom - barConsumedInset).coerceAtLeast(0)
|
||||
|
||||
return when {
|
||||
// Android R. Modify insets using their new method that exists for no reason
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
WindowInsets.Builder(insets)
|
||||
.setInsets(
|
||||
WindowInsets.Type.systemBars(),
|
||||
Insets.of(bars.left, bars.top, bars.right, adjustedBottomInset)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Android O. Modify insets using the original method
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> {
|
||||
@Suppress("DEPRECATION")
|
||||
insets.replaceSystemWindowInsets(
|
||||
bars.left, bars.top, bars.right, adjustedBottomInset
|
||||
)
|
||||
}
|
||||
|
||||
else -> insets
|
||||
}
|
||||
}
|
||||
|
||||
override fun computeScroll() {
|
||||
// Copied this from MaterialFiles.
|
||||
// Don't know what this does, but it seems important so I just keep it around.
|
||||
if (barDragHelper.continueSettling(true)) {
|
||||
postInvalidateOnAnimation()
|
||||
}
|
||||
|
@ -149,52 +218,15 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
// Prevent memory leaks
|
||||
playbackView.clearCallback()
|
||||
}
|
||||
|
||||
private fun updateWindowInsets() {
|
||||
val insets = lastInsets
|
||||
|
||||
if (insets != null) {
|
||||
val adjustedInsets = adjustInsets(insets)
|
||||
|
||||
for (child in children) {
|
||||
child.dispatchApplyWindowInsets(adjustedInsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun adjustInsets(insets: WindowInsets): WindowInsets {
|
||||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
val childConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt()
|
||||
|
||||
val bars = insets.systemBarsCompat
|
||||
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
WindowInsets.Builder(insets)
|
||||
.setInsets(
|
||||
WindowInsets.Type.systemBars(),
|
||||
Insets.of(
|
||||
bars.left, bars.top,
|
||||
bars.right, (bars.bottom - childConsumedInset).coerceAtLeast(0)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> {
|
||||
@Suppress("DEPRECATION")
|
||||
insets.replaceSystemWindowInsets(
|
||||
bars.left, bars.top,
|
||||
bars.right, (bars.bottom - childConsumedInset).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
|
||||
else -> insets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the song that this layout is showing. This will be reflected in the compact view
|
||||
* at the bottom of the screen.
|
||||
* @param animate Whether to animate bar showing/hiding events.
|
||||
*/
|
||||
fun setSong(song: Song?, animate: Boolean = false) {
|
||||
if (song != null) {
|
||||
showBar(animate)
|
||||
|
@ -204,14 +236,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the playing status on this layout. This will be reflected in the compact view
|
||||
* at the bottom of the screen.
|
||||
*/
|
||||
fun setPlaying(isPlaying: Boolean) {
|
||||
playbackView.setPlaying(isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the playback positon on this layout. This will be reflected in the compact view
|
||||
* at the bottom of the screen.
|
||||
*/
|
||||
fun setPosition(position: Long) {
|
||||
playbackView.setPosition(position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback for actions from the compact playback view in this layout.
|
||||
*/
|
||||
fun setActionCallback(callback: ActionCallback) {
|
||||
playbackView.setCallback(callback)
|
||||
}
|
||||
|
@ -220,20 +263,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
|
||||
if (barParams.shown || barParams.offset == 1f) {
|
||||
// Already showed the bar, don't do it again.
|
||||
return
|
||||
}
|
||||
|
||||
barParams.shown = true
|
||||
|
||||
if (animate) {
|
||||
// Animate, use our drag helper to slide the view upwards. All invalidation is done
|
||||
// in the callback.
|
||||
barDragHelper.smoothSlideViewTo(
|
||||
playbackView, playbackView.left, height - playbackView.height
|
||||
)
|
||||
} else {
|
||||
// Don't animate, snap the view and invalidate the content views if we are already
|
||||
// laid out. Otherwise we will do it later so don't waste time now.
|
||||
barParams.offset = 1f
|
||||
|
||||
if (isLaidOut) {
|
||||
updateWindowInsets()
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
layoutContent()
|
||||
}
|
||||
|
@ -246,20 +294,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
val barParams = playbackView.layoutParams as LayoutParams
|
||||
|
||||
if (barParams.shown || barParams.offset == 0f) {
|
||||
// Already hid the bar, don't do it again.
|
||||
return
|
||||
}
|
||||
|
||||
barParams.shown = false
|
||||
|
||||
if (animate) {
|
||||
// Animate, use our drag helper to slide the view upwards. All invalidation is done
|
||||
// in the callback.
|
||||
barDragHelper.smoothSlideViewTo(
|
||||
playbackView, playbackView.left, height
|
||||
)
|
||||
} else {
|
||||
// Don't animate, snap the view and invalidate the content views if we are already
|
||||
// laid out. Otherwise we will do it later so don't waste time now.
|
||||
barParams.offset = 0f
|
||||
|
||||
if (isLaidOut) {
|
||||
updateWindowInsets()
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
layoutContent()
|
||||
}
|
||||
|
@ -289,7 +342,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean =
|
||||
layoutParams is LayoutParams && super.checkLayoutParams(layoutParams)
|
||||
|
||||
class LayoutParams : ViewGroup.LayoutParams {
|
||||
/**
|
||||
* A callback for actions done from this view. This fragment can inherit this and recieve
|
||||
* updates from the compact playback view in this layout that can then be sent to the
|
||||
* internal playback engine.
|
||||
*
|
||||
* There is no need to clear this callback when done, the view clears it itself when the
|
||||
* view is detached.
|
||||
*/
|
||||
interface ActionCallback {
|
||||
fun onPlayPauseClick()
|
||||
fun onNavToItem()
|
||||
fun onNavToPlayback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout parameters for this layout. This layout is meant to be a black box with only two
|
||||
* types of views, so this implementation is kept private.
|
||||
*/
|
||||
private class LayoutParams : ViewGroup.LayoutParams {
|
||||
var isBar = false
|
||||
var shown = false
|
||||
var offset = 0f
|
||||
|
@ -303,13 +374,11 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
constructor(source: ViewGroup.LayoutParams) : super(source)
|
||||
}
|
||||
|
||||
interface ActionCallback {
|
||||
fun onPlayPauseClick()
|
||||
fun onNavToItem()
|
||||
fun onNavToPlayback()
|
||||
}
|
||||
|
||||
private inner class ViewDragCallback : ViewDragHelper.Callback() {
|
||||
/**
|
||||
* Internal drag callback for animating the bar view showing/hiding.
|
||||
*/
|
||||
private inner class BarDragCallback : ViewDragHelper.Callback() {
|
||||
// We aren't actually dragging things. Ignore this.
|
||||
override fun tryCaptureView(child: View, pointerId: Int): Boolean = false
|
||||
|
||||
override fun onViewPositionChanged(
|
||||
|
@ -320,12 +389,13 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
dy: Int
|
||||
) {
|
||||
val childRange = getViewVerticalDragRange(changedView)
|
||||
val childLayoutParams = changedView.layoutParams as LayoutParams
|
||||
val childParams = changedView.layoutParams as LayoutParams
|
||||
|
||||
val height = height
|
||||
childLayoutParams.offset = (height - top).toFloat() / childRange
|
||||
// Find the new offset that this view takes up after an animation frame.
|
||||
childParams.offset = (height - top).toFloat() / childRange
|
||||
|
||||
updateWindowInsets()
|
||||
// Invalidate our content views so that they accurately reflect the bar now.
|
||||
applyContentWindowInsets()
|
||||
measureContent()
|
||||
layoutContent()
|
||||
}
|
||||
|
@ -333,13 +403,14 @@ class PlaybackBarLayout @JvmOverloads constructor(
|
|||
override fun getViewVerticalDragRange(child: View): Int {
|
||||
val childParams = child.layoutParams as LayoutParams
|
||||
|
||||
return if (childParams.isBar) {
|
||||
child.height
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Sanity check
|
||||
check(childParams.isBar) { "This drag helper is only meant for content views" }
|
||||
|
||||
return child.height
|
||||
}
|
||||
|
||||
// Don't really know what these do but they're needed
|
||||
|
||||
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left
|
||||
|
||||
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.annotation.SuppressLint
|
|||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -157,6 +158,8 @@ class QueueAdapter(
|
|||
override fun onBind(data: Song) {
|
||||
binding.song = data
|
||||
|
||||
binding.background.isInvisible = true
|
||||
|
||||
binding.songName.requestLayout()
|
||||
binding.songInfo.requestLayout()
|
||||
|
||||
|
|
|
@ -87,6 +87,4 @@ class QueueFragment : Fragment() {
|
|||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
// --- QUEUE DATA ---
|
||||
}
|
||||
|
|
|
@ -140,7 +140,7 @@ fun @receiver:AttrRes Int.resolveAttr(context: Context): Int {
|
|||
|
||||
/**
|
||||
* Resolve window insets in a version-aware manner. This can be used to apply padding to
|
||||
* a view that properly
|
||||
* a view that properly follows all the frustrating changes that were made between 8-11.
|
||||
*/
|
||||
val WindowInsets.systemBarsCompat: Rect get() {
|
||||
return when {
|
||||
|
|
Loading…
Reference in a new issue