music: fix minor indexer issues

Further refine the Indexer and ExoPlayerBackend implementations.

These fixes were primarily focused on ensuring stable grouping through
stable sorting order, and more graceful handling of edge cases in
ExoPlayerBackend.
This commit is contained in:
OxygenCobalt 2022-06-01 15:49:11 -06:00
parent a64a4864bd
commit 07127403ff
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 107 additions and 76 deletions

View file

@ -7,6 +7,10 @@
#### What's Fixed #### What's Fixed
- Fixed crash when seeking to the end of a track as the track changed to a track with a lower duration - Fixed crash when seeking to the end of a track as the track changed to a track with a lower duration
- Fixed regression where GadgetBridge media controls would no longer work
#### Dev/Meta
- Switched from `LiveData` to `StateFlow`
## v2.3.0 ## v2.3.0

View file

@ -183,7 +183,7 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
// If the recyclerview can scroll, its certain that it will have to scroll to // If the recyclerview can scroll, its certain that it will have to scroll to
// correctly center the playing item, so make sure that the Toolbar is lifted in // correctly center the playing item, so make sure that the Toolbar is lifted in
// that case. // that case.
binding.detailAppbar.isLifted = binding.detailRecycler.canScroll() binding.detailAppbar.isLifted = binding.detailRecycler.canScroll
} }
} }
} }

View file

@ -289,7 +289,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun updateScrollbarState() { private fun updateScrollbarState() {
if (!canScroll() || childCount == 0) { if (!canScroll || childCount == 0) {
return return
} }

View file

@ -82,6 +82,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
runningTasks[index] = task runningTasks[index] = task
break break
} }
} }
@ -91,8 +92,6 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
// Spin until all tasks are complete // Spin until all tasks are complete
} }
// TODO: Stabilize sorting order
return songs return songs
} }
@ -122,25 +121,78 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
} }
private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) { private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
if (metadata.length() == 0) {
return
}
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
// of audio formats. Some formats (like FLAC) can contain both ID3v2 and vorbis tags, but
// this isn't too big of a deal, as we generally let the "source of truth" for metadata
// be the last instance of a particular tag in a file.
for (i in 0 until metadata.length()) { for (i in 0 until metadata.length()) {
// We only support two formats as it stands: when (val tag = metadata[i]) {
// - ID3v2 text frames is TextInformationFrame -> populateWithId3v2(audio, tag)
// - Vorbis comments is VorbisComment -> populateWithVorbis(audio, tag)
// TODO: Formats like flac can have both ID3v2 and OGG tags, so we might want to split
// up this logic.
when (val tag = metadata.get(i)) {
is TextInformationFrame ->
if (tag.value.isNotEmpty()) {
handleId3v2TextFrame(tag.id.sanitize(), tag.value.sanitize(), audio)
}
is VorbisComment ->
if (tag.value.isNotEmpty()) {
handleVorbisComment(tag.key.sanitize(), tag.value.sanitize(), audio)
}
} }
} }
} }
private fun populateWithId3v2(audio: MediaStoreBackend.Audio, frame: TextInformationFrame) {
val id = frame.id.sanitize()
val value = frame.value.sanitize()
if (value.isEmpty()) {
return
}
when (id) {
// Title
"TIT2" -> audio.title = value
// Track, as NN/TT
"TRCK" -> value.no?.let { audio.track = it }
// Disc
"TPOS" -> value.no?.let { audio.disc = it }
// ID3v2.3 year, should be digits
"TYER" -> value.toIntOrNull()?.let { audio.year = it }
// ID3v2.4 year, parse as ISO-8601
"TDRC" -> value.iso8601year?.let { audio.year = it }
// Album
"TALB" -> audio.album = value
// Artist
"TPE1" -> audio.artist = value
// Album artist
"TPE2" -> audio.albumArtist = value
// Genre, with the weird ID3v2 rules
"TCON" -> audio.genre = value
}
}
private fun populateWithVorbis(audio: MediaStoreBackend.Audio, comment: VorbisComment) {
val key = comment.key.sanitize()
val value = comment.value.sanitize()
if (value.isEmpty()) {
return
}
when (key) {
// Title
"TITLE" -> audio.title = value
// Track, presumably as NN/TT
"TRACKNUMBER" -> value.no?.let { audio.track = it }
// Disc, presumably as NN/TT
"DISCNUMBER" -> value.no?.let { audio.disc = it }
// Date, presumably as ISO-8601
"DATE" -> value.iso8601year?.let { audio.year = it }
// Album
"ALBUM" -> audio.album = value
// Artist
"ARTIST" -> audio.artist = value
// Album artist
"ALBUMARTIST" -> audio.albumArtist = value
// Genre, assumed that ID3v2 rules will apply here too.
"GENRE" -> audio.genre = value
}
}
/** /**
* Copies and sanitizes this string under the assumption that it is UTF-8. * Copies and sanitizes this string under the assumption that it is UTF-8.
* *
@ -155,35 +207,6 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
*/ */
private fun String.sanitize() = String(encodeToByteArray()) private fun String.sanitize() = String(encodeToByteArray())
private fun handleId3v2TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) {
// It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser.
when (id) {
"TIT2" -> audio.title = value // Title
"TRCK" -> value.no?.let { audio.track = it } // Track, as NN/TT
"TPOS" -> value.no?.let { audio.disc = it } // Disc, as NN/TT
"TYER" -> value.toIntOrNull()?.let { audio.year = it } // ID3v2.3 year, should be digits
"TDRC" -> value.iso8601year?.let { audio.year = it } // ID3v2.4 date, parse year field
"TALB" -> audio.album = value // Album
"TPE1" -> audio.artist = value // Artist
"TPE2" -> audio.albumArtist = value // Album artist
"TCON" -> audio.genre = value // Genre, with the weird ID3v2 rules
}
}
private fun handleVorbisComment(key: String, value: String, audio: MediaStoreBackend.Audio) {
// It's assumed that duplicate tags are eliminated by ExoPlayer's metadata parser.
when (key) {
"TITLE" -> audio.title = value // Title, presumably as NN/TT
"TRACKNUMBER" -> value.no?.let { audio.track = it } // Track, presumably as NN/TT
"DISCNUMBER" -> value.no?.let { audio.disc = it } // Disc, presumably as NN/TT
"DATE" -> value.iso8601year?.let { audio.year = it } // Date, presumably as ISO-8601
"ALBUM" -> audio.album = value // Album
"ARTIST" -> audio.artist = value // Artist
"ALBUMARTIST" -> audio.albumArtist = value // Album artist
"GENRE" -> audio.genre = value // Genre, assumed that ID3v2 rules will apply here too.
}
}
companion object { companion object {
/** The amount of tasks this backend can run efficiently at once. */ /** The amount of tasks this backend can run efficiently at once. */
private const val TASK_CAPACITY = 8 private const val TASK_CAPACITY = 8

View file

@ -108,16 +108,21 @@ object Indexer {
// Deduplicate songs to prevent (most) deformed music clones // Deduplicate songs to prevent (most) deformed music clones
songs = songs =
songs.distinctBy { songs
it.rawName to .distinctBy {
it._albumName to it.rawName to
it._artistName to it._albumName to
it._albumArtistName to it._artistName to
it._genreName to it._albumArtistName to
it.track to it._genreName to
it.disc to it.track to
it.durationMs it.disc to
} it.durationMs
}
.toMutableList()
// Ensure that sorting order is consistent so that grouping is also consistent.
Sort.ByName(true).songsInPlace(songs)
logD("Successfully loaded ${songs.size} songs") logD("Successfully loaded ${songs.size} songs")

View file

@ -151,10 +151,6 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
private var initMotionY = 0f private var initMotionY = 0f
private val tRect = Rect() private val tRect = Rect()
/** See [isDragging] */
private val dragStateField =
ViewDragHelper::class.java.getDeclaredField("mDragState").apply { isAccessible = true }
init { init {
setWillNotDraw(false) setWillNotDraw(false)
} }
@ -487,7 +483,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// want to vendor ViewDragHelper so I just do reflection instead. // want to vendor ViewDragHelper so I just do reflection instead.
val state = val state =
try { try {
dragStateField.get(this) DRAG_STATE_FIELD.get(this)
} catch (e: Exception) { } catch (e: Exception) {
ViewDragHelper.STATE_IDLE ViewDragHelper.STATE_IDLE
} }
@ -540,7 +536,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
// desynchronizing [reminder that this view also applies the bottom window inset] // desynchronizing [reminder that this view also applies the bottom window inset]
// and we can't apply padding to the whole container layout since that would adjust // and we can't apply padding to the whole container layout since that would adjust
// the size of the panel view. This seems to be the least obtrusive way to do this. // the size of the panel view. This seems to be the least obtrusive way to do this.
lastInsets?.systemBarInsetsCompat?.let { bars -> lastInsets?.let { insets ->
val bars = insets.systemBarInsetsCompat
val params = layoutParams as MarginLayoutParams val params = layoutParams as MarginLayoutParams
val oldTopMargin = params.topMargin val oldTopMargin = params.topMargin
@ -586,10 +583,9 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
get() = panelState != PanelState.HIDDEN && isEnabled get() = panelState != PanelState.HIDDEN && isEnabled
private inner class DragHelperCallback : ViewDragHelper.Callback() { private inner class DragHelperCallback : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean { // Only capture on a fully expanded panel view
// Only capture on a fully expanded panel view override fun tryCaptureView(child: View, pointerId: Int) =
return child === containerView && panelOffset >= 0 child === containerView && panelOffset >= 0
}
override fun onViewDragStateChanged(state: Int) { override fun onViewDragStateChanged(state: Int) {
if (state == ViewDragHelper.STATE_IDLE) { if (state == ViewDragHelper.STATE_IDLE) {
@ -655,9 +651,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
invalidate() invalidate()
} }
override fun getViewVerticalDragRange(child: View): Int { override fun getViewVerticalDragRange(child: View) = panelRange
return panelRange
}
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
val collapsedTop = computePanelTopPosition(0f) val collapsedTop = computePanelTopPosition(0f)
@ -668,7 +662,10 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
companion object { companion object {
private val INIT_PANEL_STATE = PanelState.HIDDEN private val INIT_PANEL_STATE = PanelState.HIDDEN
private val DRAG_STATE_FIELD =
ViewDragHelper::class.java.getDeclaredField("mDragState").apply { isAccessible = true }
private const val MIN_FLING_VEL = 400 private const val MIN_FLING_VEL = 400
private const val KEY_PANEL_STATE = BuildConfig.APPLICATION_ID + ".key.panel_state" private const val KEY_PANEL_STATE = BuildConfig.APPLICATION_ID + ".key.PANEL_STATE"
} }
} }

View file

@ -58,10 +58,9 @@ fun View.disableDropShadowCompat() {
* Determines if the point given by [x] and [y] falls within this view. * Determines if the point given by [x] and [y] falls within this view.
* @param minTouchTargetSize The minimum touch size, independent of the view's size (Optional) * @param minTouchTargetSize The minimum touch size, independent of the view's size (Optional)
*/ */
fun View.isUnder(x: Float, y: Float, minTouchTargetSize: Int = 0): Boolean { fun View.isUnder(x: Float, y: Float, minTouchTargetSize: Int = 0) =
return isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) && isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) &&
isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize) isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize)
}
private fun isUnderImpl( private fun isUnderImpl(
position: Float, position: Float,
@ -143,14 +142,17 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
} }
/** Returns whether a recyclerview can scroll. */ /** Returns whether a recyclerview can scroll. */
fun RecyclerView.canScroll(): Boolean = computeVerticalScrollRange() > height val RecyclerView.canScroll: Boolean
get() = computeVerticalScrollRange() > height
/** Converts this color to a single-color [ColorStateList]. */ /** Converts this color to a single-color [ColorStateList]. */
val @receiver:ColorRes Int.stateList val @receiver:ColorRes Int.stateList
get() = ColorStateList.valueOf(this) get() = ColorStateList.valueOf(this)
/** Require the fragment is attached to an activity. */ /** Require the fragment is attached to an activity. */
fun Fragment.requireAttached() = check(!isDetached) { "Fragment is detached from activity" } fun Fragment.requireAttached() {
check(!isDetached) { "Fragment is detached from activity" }
}
/** /**
* Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a * Launches [block] in a lifecycle-aware coroutine once [state] is reached. This is primarily a