Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/java/org/oxycblt/auxio/image/coil/CoilModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers

@Module
@InstallIn(SingletonComponent::class)
Expand Down Expand Up @@ -60,5 +61,17 @@ class CoilModule {
.transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
// Coil defaults these to Dispatchers.IO (64 threads), which lets fast scrolling
// saturate every core with cover decodes and starve the UI thread. One shared
// limiter caps total concurrent cover work.
.fetcherCoroutineContext(coverDispatcher)
.decoderCoroutineContext(coverDispatcher)
.build()

private companion object {
val coverDispatcher =
Dispatchers.IO.limitedParallelism(
(Runtime.getRuntime().availableProcessors() / 2).coerceIn(2, 4)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ abstract class CoverCompositionFetcher(
private val size: Size,
) : Fetcher {
final override suspend fun fetch(): FetchResult? {
val squareSize =
min(size.width.pxOrElse { 512 }, size.height.pxOrElse { 512 }).coerceAtLeast(1)
val bitmaps =
data.covers.covers
.asFlow()
.mapNotNull { cover -> cover.open() }
.mapNotNull { stream -> BitmapFactory.decodeStream(stream).also { stream.close() } }
.mapNotNull { cover -> cover.open()?.use { it.readBytes() } }
.mapNotNull { bytes -> decodeSampled(bytes, squareSize) }
.take(4)
.toList()
if (bitmaps.size < 4) {
Expand All @@ -69,8 +71,6 @@ abstract class CoverCompositionFetcher(
)
}

val squareSize =
min(size.width.pxOrElse { 512 }, size.height.pxOrElse { 512 }).coerceAtLeast(1)
val random = Random(data.seed)
for (i in 0..10) {
// cycle random a few times
Expand All @@ -96,6 +96,30 @@ abstract class CoverCompositionFetcher(
*/
protected abstract fun compose(bitmaps: List<Bitmap>, size: Int, random: Random): Bitmap

private fun decodeSampled(bytes: ByteArray, target: Int): Bitmap? {
val options =
BitmapFactory.Options().apply {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, this)
inSampleSize = calculateInSampleSize(outWidth, outHeight, target)
inJustDecodeBounds = false
}
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
}

/**
* The largest power-of-two downscale keeping the cover at or above [target] pixels. Four
* full-resolution covers held at once is enough to exhaust the heap.
*/
private fun calculateInSampleSize(width: Int, height: Int, target: Int): Int {
if (width <= 0 || height <= 0) return 1
var inSampleSize = 1
while (min(width, height) / (inSampleSize * 2) >= target) {
inSampleSize *= 2
}
return inSampleSize
}

protected fun drawBitmapCover(canvas: Canvas, bitmap: Bitmap, dest: RectF, paint: Paint) {
val bitmapRatio = bitmap.width.toFloat() / bitmap.height.toFloat()
val destRatio = dest.width() / dest.height()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import androidx.core.widget.TextViewCompat
import androidx.dynamicanimation.animation.FloatValueHolder
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import com.google.android.material.motion.MotionUtils
Expand Down Expand Up @@ -276,8 +278,35 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}

var popupProvider: PopupProvider? = null
set(value) {
field = value
invalidatePopupData()
}

var listener: Listener? = null

// Deriving popup text is not free and onPreDraw runs on every draw pass, so memoize it
// against the position it was derived from.
private var popupDataPos = NO_POSITION
private var popupDataText: String? = null

private val popupDataObserver =
object : AdapterDataObserver() {
override fun onChanged() = invalidatePopupData()

override fun onItemRangeChanged(positionStart: Int, itemCount: Int) =
invalidatePopupData()

override fun onItemRangeInserted(positionStart: Int, itemCount: Int) =
invalidatePopupData()

override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) =
invalidatePopupData()

override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) =
invalidatePopupData()
}

init {
overlay.add(thumbView)
overlay.add(popupView)
Expand Down Expand Up @@ -347,17 +376,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr

val provider = popupProvider
val hasPopupProvider = firstAdapterPos != NO_POSITION && provider != null
val popupData =
if (hasPopupProvider) {
provider.getPopupData(firstAdapterPos)
} else {
null
}
val popupText: String
if (hasPopupProvider) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = popupData?.text ?: "?"
if (firstAdapterPos != popupDataPos) {
popupDataPos = firstAdapterPos
// Get the popup text. If there is none, we default to "?".
popupDataText = provider.getPopupData(firstAdapterPos)?.text ?: "?"
}
popupText = popupDataText ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = true
Expand Down Expand Up @@ -420,6 +447,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
}

override fun setAdapter(adapter: Adapter<*>?) {
this.adapter?.unregisterAdapterDataObserver(popupDataObserver)
super.setAdapter(adapter)
adapter?.registerAdapterDataObserver(popupDataObserver)
invalidatePopupData()
}

private fun invalidatePopupData() {
popupDataPos = NO_POSITION
popupDataText = null
}

override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)

Expand Down Expand Up @@ -531,9 +570,49 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return
}
val dy = newOffsetY - previousOffsetY
// scrollBy lays out every row it travels past, which on a large list can be thousands of
// rows in one touch event. Past a couple of screenfuls, jump instead of walking.
if (abs(dy) > height * MAX_SCROLL_BY_SCREENS && jumpToOffset(newOffsetY)) {
return
}
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
}

/**
* Jump directly to the row nearest [offsetY], estimated off the average laid-out row height.
* Returns false if there is nothing laid out to estimate from.
*/
private fun jumpToOffset(offsetY: Float): Boolean {
val layoutManager = layoutManager as? LinearLayoutManager ?: return false
val rowHeight = averageRowHeight()
if (rowHeight <= 0) {
return false
}
val itemCount = adapter?.itemCount ?: return false
if (itemCount == 0) {
return false
}
val spanCount = (layoutManager as? GridLayoutManager)?.spanCount ?: 1
val row = (offsetY / rowHeight).toInt()
val position = (row * spanCount).coerceIn(0, itemCount - 1)
val positionOffset = -(offsetY.toInt() % rowHeight)
stopScroll()
layoutManager.scrollToPositionWithOffset(position, positionOffset)
return true
}

private fun averageRowHeight(): Int {
val childCount = childCount
if (childCount == 0) {
return 0
}
var total = 0
for (i in 0 until childCount) {
total += getChildAt(i).height
}
return total / childCount
}

// --- SCROLLBAR APPEARANCE ---

private fun postAutoHideScrollbar() {
Expand Down Expand Up @@ -759,6 +838,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
const val POPUP_SHAPE_HIDDEN_SCALE = 0.62f
const val POPUP_TEXT_HIDDEN_SCALE = 0.78f
const val POPUP_TEXT_SINGLE_LINE_COUNT = 1

/**
* How many screenfuls of content a fast-scroll drag may cover with an incremental scroll
* before it jumps instead. Bounds the worst-case number of rows laid out inside a single
* touch event.
*/
const val MAX_SCROLL_BY_SCREENS = 2
}

private data class PopupTextAutoScaleKey(val exemplar: String?, val popupSize: Int)
Expand Down
17 changes: 16 additions & 1 deletion app/src/main/java/org/oxycblt/auxio/playback/PlaybackUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ fun Long.dsToSecs() = floorDiv(10)
*/
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)

private var popupDurationLocale: Locale? = null
private var popupDurationFormat: MeasureFormat? = null

/** The [MeasureFormat] used by [formatDurationMsPopup], memoized against the current locale. */
private fun popupDurationFormat(): MeasureFormat {
val locale = Locale.getDefault()
var format = popupDurationFormat
if (format == null || popupDurationLocale != locale) {
format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.NARROW)
popupDurationFormat = format
popupDurationLocale = locale
}
return format
}

/**
* Format a millisecond duration into a compact, locale-aware bucket string suitable for fast-scroll
* popups. Durations are bucketed into the most significant time unit:
Expand All @@ -70,7 +85,7 @@ fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(is
fun Long.formatDurationMsPopup(): String {
val totalMinutes = floorDiv(60_000)
val totalHours = totalMinutes / 60
val fmt = MeasureFormat.getInstance(Locale.getDefault(), MeasureFormat.FormatWidth.NARROW)
val fmt = popupDurationFormat()
return when {
totalMinutes < 1 -> "<" + fmt.format(Measure(1, MeasureUnit.MINUTE))
totalHours < 1 -> fmt.format(Measure(totalMinutes, MeasureUnit.MINUTE))
Expand Down
Loading