all: document custom stuff

Document and clean up PlaybackBarLayout and the fast scroll views to an
extent.
This commit is contained in:
OxygenCobalt 2021-11-07 19:02:50 -07:00
parent 8b8d36cf22
commit 1b79eb11e0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 342 additions and 267 deletions

View file

@ -83,7 +83,7 @@ class ExcludedDialog : LifecycleDialog() {
} }
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
if (excludedModel.isModified()) { if (excludedModel.isModified) {
saveAndRestart() saveAndRestart()
} else { } else {
dismiss() dismiss()

View file

@ -33,13 +33,17 @@ import kotlinx.coroutines.withContext
* of paths. Use [Factory] to instantiate this. * of paths. Use [Factory] to instantiate this.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedViewModel(context: Context) : ViewModel() { class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
private val mPaths = MutableLiveData(mutableListOf<String>()) private val mPaths = MutableLiveData(mutableListOf<String>())
val paths: LiveData<MutableList<String>> get() = mPaths val paths: LiveData<MutableList<String>> get() = mPaths
private val excludedDatabase = ExcludedDatabase.getInstance(context)
private var dbPaths = listOf<String>() private var dbPaths = listOf<String>()
/**
* Check if changes have been made to the ViewModel's paths.
*/
val isModified: Boolean get() = dbPaths != paths.value
init { init {
loadDatabasePaths() 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 { class Factory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) { check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) {
@ -101,7 +100,7 @@ class ExcludedViewModel(context: Context) : ViewModel() {
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return ExcludedViewModel(context) as T return ExcludedViewModel(ExcludedDatabase.getInstance(context)) as T
} }
} }
} }

View file

@ -46,112 +46,114 @@ import kotlin.math.sqrt
* !!! MODIFICATIONS !!!: * !!! MODIFICATIONS !!!:
* - Use modified Auxio resources instead of AFS resources * - Use modified Auxio resources instead of AFS resources
* - Variable names are no longer prefixed with m * - 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() { class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint = Paint() private val paint: Paint = Paint().apply {
private val paddingStart: Int isAntiAlias = true
private val paddingEnd: Int color = R.attr.colorControlActivated.resolveAttr(context)
style = Paint.Style.FILL
}
private val path = Path() 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) { override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint) canvas.drawPath(path, paint)
} }
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath() updatePath()
return true return true
} }
override fun setAlpha(alpha: Int) {} @Suppress("DEPRECATION")
override fun setColorFilter(colorFilter: ColorFilter?) {} override fun getOutline(outline: Outline) {
override fun isAutoMirrored(): Boolean { when {
return true Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
}
private fun needMirroring(): Boolean { // Paths don't need to be convex on android Q, but the API was mislabeled and so
return DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL // we still have to use this method.
} Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
override fun getOpacity(): Int { else -> if (!path.isConvex) {
return PixelFormat.TRANSLUCENT // 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.
override fun onBoundsChange(bounds: Rect) { super.getOutline(outline)
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()
} }
tempMatrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
path.transform(tempMatrix)
} }
override fun getPadding(padding: Rect): Boolean { override fun getPadding(padding: Rect): Boolean {
if (needMirroring()) { if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0 padding[paddingEnd, 0, paddingStart] = 0
} else { } else {
padding[paddingStart, 0, paddingEnd] = 0 padding[paddingStart, 0, paddingEnd] = 0
} }
return true return true
} }
@Suppress("DEPRECATION") override fun isAutoMirrored(): Boolean = true
override fun getOutline(outline: Outline) { override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !path.isConvex) { override fun setAlpha(alpha: Int) {}
// The outline path must be convex before Q, but we may run into floating point error override fun setColorFilter(colorFilter: ColorFilter?) {}
// caused by calculation involving sqrt(2) or OEM implementation difference, so in this
// case we just omit the shadow instead of crashing. private fun updatePath() {
super.getOutline(outline) path.reset()
return
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(
private fun pathArcTo( path: Path,
path: Path, centerX: Float,
centerX: Float, centerY: Float,
centerY: Float, radius: Float,
radius: Float, startAngle: Float,
startAngle: Float, sweepAngle: Float
sweepAngle: Float ) {
) { path.arcTo(
path.arcTo( centerX - radius, centerY - radius, centerX + radius, centerY + radius,
centerX - radius, centerY - radius, centerX + radius, centerY + radius, startAngle, sweepAngle, false
startAngle, sweepAngle, false )
)
}
} }
init { private val isRtl: Boolean get() =
paint.isAntiAlias = true DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
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)
}
} }

View file

@ -142,7 +142,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
setLayoutParams(layoutParams) setLayoutParams(layoutParams)
background = Md2PopupBackground(context) background = FastScrollPopupDrawable(context)
elevation = resources.getDimensionPixelOffset(R.dimen.elevation_normal).toFloat() elevation = resources.getDimensionPixelOffset(R.dimen.elevation_normal).toFloat()
ellipsize = TextUtils.TruncateAt.MIDDLE ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER gravity = Gravity.CENTER
@ -335,7 +335,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
getDecoratedBoundsWithMargins(getChildAt(0), childRect) getDecoratedBoundsWithMargins(getChildAt(0), childRect)
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - childRect.top 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] // [proportion of scroll position to scroll range] * [total thumb range]
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt() thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
} }
@ -396,25 +396,14 @@ class FastScrollRecyclerView @JvmOverloads constructor(
} }
private fun isInViewTouchTarget(view: View, x: Float, y: Float): Boolean { private fun isInViewTouchTarget(view: View, x: Float, y: Float): Boolean {
val scrollX = scrollX return isInTouchTarget(x, view.left - scrollX, view.right - scrollX, width) &&
val scrollY = scrollY isInTouchTarget(y, view.top - scrollY, view.bottom - scrollY, height)
return (
isInTouchTarget(
x, view.left - scrollX, view.right - scrollX, 0,
width
) &&
isInTouchTarget(
y, view.top - scrollY, view.bottom - scrollY, 0,
height
)
)
} }
private fun isInTouchTarget( private fun isInTouchTarget(
position: Float, position: Float,
viewStart: Int, viewStart: Int,
viewEnd: Int, viewEnd: Int,
parentStart: Int,
parentEnd: Int parentEnd: Int
): Boolean { ): Boolean {
val viewSize = viewEnd - viewStart val viewSize = viewEnd - viewStart
@ -424,16 +413,18 @@ class FastScrollRecyclerView @JvmOverloads constructor(
} }
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2 var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
if (touchTargetStart < parentStart) {
touchTargetStart = parentStart if (touchTargetStart < 0) {
touchTargetStart = 0
} }
var touchTargetEnd = touchTargetStart + minTouchTargetSize var touchTargetEnd = touchTargetStart + minTouchTargetSize
if (touchTargetEnd > parentEnd) { if (touchTargetEnd > parentEnd) {
touchTargetEnd = parentEnd touchTargetEnd = parentEnd
touchTargetStart = touchTargetEnd - minTouchTargetSize 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) { private fun scrollToThumbOffset(thumbOffset: Int) {
var newThumbOffset = thumbOffset val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange)
newThumbOffset = MathUtils.clamp(newThumbOffset, 0, thumbOffsetRange)
var scrollOffset = (scrollOffsetRange.toLong() * newThumbOffset / thumbOffsetRange).toInt() val scrollOffset = (
scrollOffset -= paddingTop scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange
).toInt() - paddingTop
scrollTo(scrollOffset) scrollTo(scrollOffset)
} }
@ -454,6 +446,7 @@ class FastScrollRecyclerView @JvmOverloads constructor(
val trueOffset = offset - paddingTop val trueOffset = offset - paddingTop
val itemHeight = itemHeight val itemHeight = itemHeight
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight) val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
val firstItemTop = firstItemPosition * itemHeight - trueOffset val firstItemTop = firstItemPosition * itemHeight - trueOffset
@ -557,32 +550,11 @@ class FastScrollRecyclerView @JvmOverloads constructor(
private val isRtl: Boolean private val isRtl: Boolean
get() = layoutDirection == LAYOUT_DIRECTION_RTL get() = layoutDirection == LAYOUT_DIRECTION_RTL
private val scrollOffsetRange: Int
get() = scrollRange - height
private val thumbOffsetRange: Int private val thumbOffsetRange: Int
get() { get() {
return height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight 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 private val scrollRange: Int
get() { get() {
val itemCount = itemCount val itemCount = itemCount
@ -600,6 +572,9 @@ class FastScrollRecyclerView @JvmOverloads constructor(
} }
} }
private val scrollOffsetRange: Int
get() = scrollRange - height
private val firstAdapterPos: Int private val firstAdapterPos: Int
get() { get() {
if (childCount == 0) { 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 { companion object {
private const val ANIM_MILLIS = 150L private const val ANIM_MILLIS = 150L
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500 private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500

View file

@ -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] * as an [Album] or [Artist]
* @property resolvedName A name resolved from it's raw form to a form suitable to be shown in * @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() { sealed class MusicParent : Music() {
abstract val resolvedName: String abstract val resolvedName: String
@ -200,10 +200,55 @@ data class Genre(
override val hash = name.hashCode().toLong() 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 * 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 * around passing string resources that are then resolved by the view. This is because ViewModel
* directly. * 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 * @author OxygenCobalt
*/ */
sealed class HeaderString { 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
}
}

View file

@ -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 * 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 * 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 * 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 * 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! * 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 { 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() }.toMutableList()
logD("Song search finished with ${songs.size} found") logD("Song search finished with ${songs.size} found")

View file

@ -22,7 +22,9 @@ import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.drawable.RippleDrawable import android.graphics.drawable.RippleDrawable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.updatePadding
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.databinding.ViewCompactPlaybackBinding 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.inflater
import org.oxycblt.auxio.util.resolveAttr import org.oxycblt.auxio.util.resolveAttr
import org.oxycblt.auxio.util.resolveDrawable 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 * 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) { fun setSong(song: Song) {
binding.song = song binding.song = song
binding.executePendingBindings() binding.executePendingBindings()

View file

@ -28,7 +28,6 @@ import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.updatePadding
import androidx.customview.widget.ViewDragHelper import androidx.customview.widget.ViewDragHelper
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.systemBarsCompat 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 * 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 * 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 * this class was primarily written by me.
* the playback navigation process.
* *
* TODO: Explain how this thing works so that others can be spared the pain of deciphering * TODO: Add a swipe-up behavior a la Phonograph. I think that would improve UX.
* this custom viewgroup * TODO: Leverage this layout to make more tablet-friendly UIs
*
* @author OxygenCobalt
*/ */
class PlaybackBarLayout @JvmOverloads constructor( class PlaybackBarLayout @JvmOverloads constructor(
context: Context, context: Context,
@ -49,12 +49,14 @@ class PlaybackBarLayout @JvmOverloads constructor(
@StyleRes defStyleRes: Int = 0 @StyleRes defStyleRes: Int = 0
) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) {
private val playbackView = CompactPlaybackView(context) private val playbackView = CompactPlaybackView(context)
private var barDragHelper = ViewDragHelper.create(this, ViewDragCallback()) private var barDragHelper = ViewDragHelper.create(this, BarDragCallback())
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
init { init {
addView(playbackView) 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 { (playbackView.layoutParams as LayoutParams).apply {
width = ViewGroup.LayoutParams.MATCH_PARENT width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.WRAP_CONTENT height = ViewGroup.LayoutParams.WRAP_CONTENT
@ -70,16 +72,21 @@ class PlaybackBarLayout @JvmOverloads constructor(
setMeasuredDimension(widthSize, heightSize) 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 barParams = playbackView.layoutParams as LayoutParams
val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width) val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width)
val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height) val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height)
playbackView.measure(barWidthSpec, barHeightSpec) playbackView.measure(barWidthSpec, barHeightSpec)
updateWindowInsets() applyContentWindowInsets()
measureContent() 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() { private fun measureContent() {
val barParams = playbackView.layoutParams as LayoutParams val barParams = playbackView.layoutParams as LayoutParams
val barHeightAdjusted = (playbackView.measuredHeight * barParams.offset).toInt() 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) { override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val barHeight = playbackView.measuredHeight 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) { // Again, lay out our view like we measured it.
if (child.visibility == View.GONE) continue playbackView.layout(
0, height - barHeightAdjusted,
val childParams = child.layoutParams as LayoutParams width, height + (barHeight - barHeightAdjusted)
)
if (childParams.isBar) {
child.layout(
0, height - barHeightAdjusted,
width, height + (barHeight - barHeightAdjusted)
)
}
}
layoutContent() 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() { private fun layoutContent() {
for (child in children) { for (child in children) {
val childParams = child.layoutParams as LayoutParams val childParams = child.layoutParams as LayoutParams
@ -132,15 +137,79 @@ class PlaybackBarLayout @JvmOverloads constructor(
} }
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { 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 lastInsets = insets
updateWindowInsets() applyContentWindowInsets()
return insets 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() { 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)) { if (barDragHelper.continueSettling(true)) {
postInvalidateOnAnimation() postInvalidateOnAnimation()
} }
@ -149,52 +218,15 @@ class PlaybackBarLayout @JvmOverloads constructor(
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
// Prevent memory leaks
playbackView.clearCallback() playbackView.clearCallback()
} }
private fun updateWindowInsets() { /**
val insets = lastInsets * Update the song that this layout is showing. This will be reflected in the compact view
* at the bottom of the screen.
if (insets != null) { * @param animate Whether to animate bar showing/hiding events.
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
}
}
fun setSong(song: Song?, animate: Boolean = false) { fun setSong(song: Song?, animate: Boolean = false) {
if (song != null) { if (song != null) {
showBar(animate) 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) { fun setPlaying(isPlaying: Boolean) {
playbackView.setPlaying(isPlaying) 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) { fun setPosition(position: Long) {
playbackView.setPosition(position) playbackView.setPosition(position)
} }
/**
* Add a callback for actions from the compact playback view in this layout.
*/
fun setActionCallback(callback: ActionCallback) { fun setActionCallback(callback: ActionCallback) {
playbackView.setCallback(callback) playbackView.setCallback(callback)
} }
@ -220,20 +263,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
val barParams = playbackView.layoutParams as LayoutParams val barParams = playbackView.layoutParams as LayoutParams
if (barParams.shown || barParams.offset == 1f) { if (barParams.shown || barParams.offset == 1f) {
// Already showed the bar, don't do it again.
return return
} }
barParams.shown = true barParams.shown = true
if (animate) { if (animate) {
// Animate, use our drag helper to slide the view upwards. All invalidation is done
// in the callback.
barDragHelper.smoothSlideViewTo( barDragHelper.smoothSlideViewTo(
playbackView, playbackView.left, height - playbackView.height playbackView, playbackView.left, height - playbackView.height
) )
} else { } 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 barParams.offset = 1f
if (isLaidOut) { if (isLaidOut) {
updateWindowInsets() applyContentWindowInsets()
measureContent() measureContent()
layoutContent() layoutContent()
} }
@ -246,20 +294,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
val barParams = playbackView.layoutParams as LayoutParams val barParams = playbackView.layoutParams as LayoutParams
if (barParams.shown || barParams.offset == 0f) { if (barParams.shown || barParams.offset == 0f) {
// Already hid the bar, don't do it again.
return return
} }
barParams.shown = false barParams.shown = false
if (animate) { if (animate) {
// Animate, use our drag helper to slide the view upwards. All invalidation is done
// in the callback.
barDragHelper.smoothSlideViewTo( barDragHelper.smoothSlideViewTo(
playbackView, playbackView.left, height playbackView, playbackView.left, height
) )
} else { } 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 barParams.offset = 0f
if (isLaidOut) { if (isLaidOut) {
updateWindowInsets() applyContentWindowInsets()
measureContent() measureContent()
layoutContent() layoutContent()
} }
@ -289,7 +342,25 @@ class PlaybackBarLayout @JvmOverloads constructor(
override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean = override fun checkLayoutParams(layoutParams: ViewGroup.LayoutParams): Boolean =
layoutParams is LayoutParams && super.checkLayoutParams(layoutParams) 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 isBar = false
var shown = false var shown = false
var offset = 0f var offset = 0f
@ -303,13 +374,11 @@ class PlaybackBarLayout @JvmOverloads constructor(
constructor(source: ViewGroup.LayoutParams) : super(source) constructor(source: ViewGroup.LayoutParams) : super(source)
} }
interface ActionCallback { /**
fun onPlayPauseClick() * Internal drag callback for animating the bar view showing/hiding.
fun onNavToItem() */
fun onNavToPlayback() private inner class BarDragCallback : ViewDragHelper.Callback() {
} // We aren't actually dragging things. Ignore this.
private inner class ViewDragCallback : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean = false override fun tryCaptureView(child: View, pointerId: Int): Boolean = false
override fun onViewPositionChanged( override fun onViewPositionChanged(
@ -320,12 +389,13 @@ class PlaybackBarLayout @JvmOverloads constructor(
dy: Int dy: Int
) { ) {
val childRange = getViewVerticalDragRange(changedView) val childRange = getViewVerticalDragRange(changedView)
val childLayoutParams = changedView.layoutParams as LayoutParams val childParams = changedView.layoutParams as LayoutParams
val height = height // Find the new offset that this view takes up after an animation frame.
childLayoutParams.offset = (height - top).toFloat() / childRange childParams.offset = (height - top).toFloat() / childRange
updateWindowInsets() // Invalidate our content views so that they accurately reflect the bar now.
applyContentWindowInsets()
measureContent() measureContent()
layoutContent() layoutContent()
} }
@ -333,13 +403,14 @@ class PlaybackBarLayout @JvmOverloads constructor(
override fun getViewVerticalDragRange(child: View): Int { override fun getViewVerticalDragRange(child: View): Int {
val childParams = child.layoutParams as LayoutParams val childParams = child.layoutParams as LayoutParams
return if (childParams.isBar) { // Sanity check
child.height check(childParams.isBar) { "This drag helper is only meant for content views" }
} else {
0 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 clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = child.left
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {

View file

@ -22,6 +22,7 @@ import android.annotation.SuppressLint
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -157,6 +158,8 @@ class QueueAdapter(
override fun onBind(data: Song) { override fun onBind(data: Song) {
binding.song = data binding.song = data
binding.background.isInvisible = true
binding.songName.requestLayout() binding.songName.requestLayout()
binding.songInfo.requestLayout() binding.songInfo.requestLayout()

View file

@ -87,6 +87,4 @@ class QueueFragment : Fragment() {
return binding.root return binding.root
} }
// --- QUEUE DATA ---
} }

View file

@ -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 * 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() { val WindowInsets.systemBarsCompat: Rect get() {
return when { return when {