sort: completely rework class

Completely refactor the Sort class to be more efficient and
straightforward.

The original Sort class had *major* shortcomings, it was slow,
poorly organized, and relied on abusing compareBy to implement
special things like article sort. This rework eliminates all of
that in favor of a new system relying on custom comparators and
chaining to achieve something much faster and maintainable.
This commit is contained in:
OxygenCobalt 2022-03-19 15:41:03 -06:00
parent 90f10f2a84
commit 9c07ad2d34
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 201 additions and 129 deletions

View file

@ -36,6 +36,9 @@ import org.oxycblt.auxio.settings.SettingsManager
* - Rework RecyclerView management and item dragging
* - Rework sealed classes to minimize whens and maximize overrides
* ```
*
* TODO: Dumpster int-codes for a 4-byte identifier (can still be in the form of an int) For
* example, instead of 0xA111 for ReplayGainMode.TRACK, you would instead have RTCK
*/
@Suppress("UNUSED")
class AuxioApp : Application(), ImageLoaderFactory {

View file

@ -74,7 +74,7 @@ private constructor(
private val artist: Artist,
) : BaseFetcher() {
override suspend fun fetch(): FetchResult? {
val albums = Sort.ByName(true).sortAlbums(artist.albums)
val albums = Sort.ByName(true).albums(artist.albums)
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
return createMosaic(context, results, size)

View file

@ -162,7 +162,7 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort)
}))
data.addAll(settingsManager.detailGenreSort.sortGenre(curGenre.value!!))
data.addAll(settingsManager.detailGenreSort.genre(curGenre.value!!))
mGenreData.value = data
}
@ -174,7 +174,7 @@ class DetailViewModel : ViewModel() {
data.add(Header(id = -2, string = R.string.lbl_albums))
data.addAll(Sort.ByYear(false).sortAlbums(artist.albums))
data.addAll(Sort.ByYear(false).albums(artist.albums))
data.add(
ActionHeader(
@ -187,7 +187,7 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = MenuConfig(view, settingsManager.detailArtistSort)
}))
data.addAll(settingsManager.detailArtistSort.sortArtist(artist))
data.addAll(settingsManager.detailArtistSort.artist(artist))
mArtistData.value = data.toList()
}
@ -208,7 +208,7 @@ class DetailViewModel : ViewModel() {
mShowMenu.value = MenuConfig(view, settingsManager.detailAlbumSort)
}))
data.addAll(settingsManager.detailAlbumSort.sortAlbum(curAlbum.value!!))
data.addAll(settingsManager.detailAlbumSort.album(curAlbum.value!!))
mAlbumData.value = data
}

View file

@ -81,10 +81,10 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
viewModelScope.launch {
val musicStore = MusicStore.awaitInstance()
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortParents(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.sortParents(musicStore.genres)
mSongs.value = settingsManager.libSongSort.songs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.albums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.artists(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.genres(musicStore.genres)
}
}
@ -113,19 +113,19 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
when (mCurTab.value) {
DisplayMode.SHOW_SONGS -> {
settingsManager.libSongSort = sort
mSongs.value = sort.sortSongs(mSongs.value!!)
mSongs.value = sort.songs(mSongs.value!!)
}
DisplayMode.SHOW_ALBUMS -> {
settingsManager.libAlbumSort = sort
mAlbums.value = sort.sortAlbums(mAlbums.value!!)
mAlbums.value = sort.albums(mAlbums.value!!)
}
DisplayMode.SHOW_ARTISTS -> {
settingsManager.libArtistSort = sort
mArtists.value = sort.sortParents(mArtists.value!!)
mArtists.value = sort.artists(mArtists.value!!)
}
DisplayMode.SHOW_GENRES -> {
settingsManager.libGenreSort = sort
mGenres.value = sort.sortParents(mGenres.value!!)
mGenres.value = sort.genres(mGenres.value!!)
}
else -> {}
}

View file

@ -69,19 +69,11 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added documentation
*
* @author Hai Zhang, OxygenCobalt
*
* TODO: Fix strange touch behavior when the pointer is slightly outside of the view.
*
* TODO: Really try to make this view less insane.
*/
class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
EdgeRecyclerView(context, attrs, defStyleAttr) {
private val minTouchTargetSize =
context.getDimenSizeSafe(R.dimen.fast_scroll_thumb_touch_target_size)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
// Thumb
private val thumbView =
View(context).apply {
@ -102,6 +94,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
private val scrollPositionChildRect = Rect()
// Popup
private val popupView =
FastScrollPopupView(context).apply {
@ -118,7 +112,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var showingPopup = false
// Touch events
// Touch
private val minTouchTargetSize =
context.getDimenSizeSafe(R.dimen.fast_scroll_thumb_touch_target_size)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
private var downY = 0f
private var lastY = 0f
@ -151,8 +149,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
onDragListener?.invoke(value)
}
private val childRect = Rect()
/** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null
@ -303,8 +299,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), childRect)
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - childRect.top
getDecoratedBoundsWithMargins(getChildAt(0), scrollPositionChildRect)
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - scrollPositionChildRect.top
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
@ -497,8 +493,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
val itemView = getChildAt(0)
getDecoratedBoundsWithMargins(itemView, childRect)
return childRect.height()
getDecoratedBoundsWithMargins(itemView, scrollPositionChildRect)
return scrollPositionChildRect.height()
}
private val itemCount: Int

View file

@ -27,6 +27,7 @@ import androidx.core.database.getStringOrNull
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.logD
/**
@ -277,9 +278,17 @@ class MusicLoader {
// Use the song with the latest year as our metadata song.
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives.
// TODO: Weigh songs with null years lower than songs with zero years
val templateSong =
requireNotNull(albumSongs.maxByOrNull { it.internalMediaStoreYear ?: 0 })
// Note: Normally we could want to use something like maxByWith, but apparently
// that does not exist in the kotlin stdlib yet.
val comparator = Sort.NullableComparator<Int>()
var templateSong = albumSongs[0]
for (i in 1..albumSongs.lastIndex) {
val candidate = albumSongs[i]
if (comparator.compare(templateSong.track, candidate.track) < 0) {
templateSong = candidate
}
}
val albumName = templateSong.internalMediaStoreAlbumName
val albumYear = templateSong.internalMediaStoreYear
val albumCoverUri =

View file

@ -240,7 +240,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** Add an [Album] to the top of the queue. */
fun playNext(album: Album) {
playbackManager.playNext(settingsManager.detailAlbumSort.sortAlbum(album))
playbackManager.playNext(settingsManager.detailAlbumSort.album(album))
}
/** Add a [Song] to the end of the queue. */
@ -250,7 +250,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
/** Add an [Album] to the end of the queue. */
fun addToQueue(album: Album) {
playbackManager.addToQueue(settingsManager.detailAlbumSort.sortAlbum(album))
playbackManager.addToQueue(settingsManager.detailAlbumSort.album(album))
}
// --- STATUS FUNCTIONS ---

View file

@ -376,13 +376,13 @@ class PlaybackStateManager private constructor() {
mQueue =
when (mPlaybackMode) {
PlaybackMode.ALL_SONGS ->
settingsManager.libSongSort.sortSongs(musicStore.songs).toMutableList()
settingsManager.libSongSort.songs(musicStore.songs).toMutableList()
PlaybackMode.IN_ALBUM ->
settingsManager.detailAlbumSort.sortAlbum(mParent as Album).toMutableList()
settingsManager.detailAlbumSort.album(mParent as Album).toMutableList()
PlaybackMode.IN_ARTIST ->
settingsManager.detailArtistSort.sortArtist(mParent as Artist).toMutableList()
settingsManager.detailArtistSort.artist(mParent as Artist).toMutableList()
PlaybackMode.IN_GENRE ->
settingsManager.detailGenreSort.sortGenre(mParent as Genre).toMutableList()
settingsManager.detailGenreSort.genre(mParent as Genre).toMutableList()
}
if (keepSong) {

View file

@ -88,28 +88,28 @@ class SearchViewModel : ViewModel() {
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(query)?.let { artists ->
results.add(Header(-1, R.string.lbl_artists))
results.addAll(sort.sortParents(artists))
results.addAll(sort.artists(artists))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(query)?.let { albums ->
results.add(Header(-2, R.string.lbl_albums))
results.addAll(sort.sortAlbums(albums))
results.addAll(sort.albums(albums))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(query)?.let { genres ->
results.add(Header(-3, R.string.lbl_genres))
results.addAll(sort.sortParents(genres))
results.addAll(sort.genres(genres))
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(query)?.let { songs ->
results.add(Header(-4, R.string.lbl_songs))
results.addAll(sort.sortSongs(songs))
results.addAll(sort.songs(songs))
}
}

View file

@ -23,8 +23,8 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logW
/**
* A data class representing the sort modes used in Auxio.
@ -43,14 +43,95 @@ import org.oxycblt.auxio.music.Song
* @author OxygenCobalt
*/
sealed class Sort(open val isAscending: Boolean) {
open fun songs(songs: Collection<Song>): List<Song> {
logW("This sort is not supported for songs")
return songs.toList()
}
open fun albums(albums: Collection<Album>): List<Album> {
logW("This sort is not supported for albums")
return albums.toList()
}
open fun artists(artists: Collection<Artist>): List<Artist> {
logW("This sort is not supported for artists")
return artists.toList()
}
open fun genres(genres: Collection<Genre>): List<Genre> {
logW("This sort is not supported for genres")
return genres.toList()
}
/** Sort by the names of an item */
class ByName(override val isAscending: Boolean) : Sort(isAscending)
/** Sort by the artist of an item, only supported by [Album] and [Song] */
class ByArtist(override val isAscending: Boolean) : Sort(isAscending)
class ByName(override val isAscending: Boolean) : Sort(isAscending) {
override fun songs(songs: Collection<Song>): List<Song> {
return songs.sortedWith(compareByDynamic(NameComparator()) { it })
}
override fun albums(albums: Collection<Album>): List<Album> {
return albums.sortedWith(compareByDynamic(NameComparator()) { it })
}
override fun artists(artists: Collection<Artist>): List<Artist> {
return artists.sortedWith(compareByDynamic(NameComparator()) { it })
}
override fun genres(genres: Collection<Genre>): List<Genre> {
return genres.sortedWith(compareByDynamic(NameComparator()) { it })
}
}
/** Sort by the album of an item, only supported by [Song] */
class ByAlbum(override val isAscending: Boolean) : Sort(isAscending)
class ByAlbum(override val isAscending: Boolean) : Sort(isAscending) {
override fun songs(songs: Collection<Song>): List<Song> {
return songs.sortedWith(
MultiComparator(
compareByDynamic(NameComparator()) { it.album },
compareBy(NullableComparator()) { it.track },
compareBy(NameComparator()) { it }))
}
}
/** Sort by the artist of an item, only supported by [Album] and [Song] */
class ByArtist(override val isAscending: Boolean) : Sort(isAscending) {
override fun songs(songs: Collection<Song>): List<Song> {
return songs.sortedWith(
MultiComparator(
compareByDynamic(NameComparator()) { it.album.artist },
compareByDescending(NullableComparator()) { it.album.year },
compareByDescending(NameComparator()) { it.album },
compareBy(NullableComparator()) { it.track },
compareBy(NameComparator()) { it }))
}
override fun albums(albums: Collection<Album>): List<Album> {
return albums.sortedWith(
MultiComparator(
compareByDynamic(NameComparator()) { it.artist },
compareByDescending(NullableComparator()) { it.year },
compareBy(NameComparator()) { it }))
}
}
/** Sort by the year of an item, only supported by [Album] and [Song] */
class ByYear(override val isAscending: Boolean) : Sort(isAscending)
class ByYear(override val isAscending: Boolean) : Sort(isAscending) {
override fun songs(songs: Collection<Song>): List<Song> {
return songs.sortedWith(
MultiComparator(
compareByDynamic(NullableComparator()) { it.album.year },
compareByDescending(NameComparator()) { it.album },
compareBy(NullableComparator()) { it.track },
compareBy(NameComparator()) { it }))
}
override fun albums(albums: Collection<Album>): List<Album> {
return albums.sortedWith(
MultiComparator(
compareByDynamic(NullableComparator()) { it.year },
compareBy(NameComparator()) { it }))
}
}
/** Get the corresponding item id for this sort. */
val itemId: Int
@ -89,72 +170,31 @@ sealed class Sort(open val isAscending: Boolean) {
}
}
/**
* Sort a list of [Song] instances to reflect this specific sort.
*
* Albums are sorted by ascending track, artists are sorted with [ByYear] descending.
*
* @return A sorted list of songs
*/
fun sortSongs(songs: Collection<Song>): List<Song> {
return when (this) {
is ByName -> songs.stringSort { it.resolvedName }
else ->
sortAlbums(songs.groupBy { it.album }.keys).flatMap { album ->
album.songs.intSort(true) { it.track ?: 0 }
}
}
}
/**
* Sort a list of [Album] instances to reflect this specific sort.
*
* Artists are sorted with [ByYear] descending.
*
* @return A sorted list of albums
*/
fun sortAlbums(albums: Collection<Album>): List<Album> {
return when (this) {
is ByName, is ByAlbum -> albums.stringSort { it.resolvedName }
is ByArtist ->
sortParents(albums.groupBy { it.artist }.keys).flatMap {
ByYear(false).sortAlbums(it.albums)
}
is ByYear -> albums.intSort { it.year ?: 0 }
}
}
/**
* Sort a list of [MusicParent] instances to reflect this specific sort.
*
* @return A sorted list of the specific parent
*/
fun <T : MusicParent> sortParents(parents: Collection<T>): List<T> {
return parents.stringSort { it.resolvedName }
}
/**
* Sort the songs in an album.
* @see sortSongs
* @see songs
*/
fun sortAlbum(album: Album): List<Song> {
return album.songs.intSort { it.track ?: 0 }
fun album(album: Album): List<Song> {
return album.songs.sortedWith(
MultiComparator(
compareByDynamic(NullableComparator()) { it.track },
compareBy(NameComparator()) { it }))
}
/**
* Sort the songs in an artist.
* @see sortSongs
* @see songs
*/
fun sortArtist(artist: Artist): List<Song> {
return sortSongs(artist.songs)
fun artist(artist: Artist): List<Song> {
return songs(artist.songs)
}
/**
* Sort the songs in a genre.
* @see sortSongs
* @see songs
*/
fun sortGenre(genre: Genre): List<Song> {
return sortSongs(genre.songs)
fun genre(genre: Genre): List<Song> {
return songs(genre.songs)
}
/** Convert this sort to it's integer representation. */
@ -167,35 +207,59 @@ sealed class Sort(open val isAscending: Boolean) {
}.shl(1) or if (isAscending) 1 else 0
}
private fun <T : Music> Collection<T>.stringSort(
asc: Boolean = isAscending,
selector: (T) -> String
): List<T> {
// Chain whatever item call with sliceArticle for correctness
val chained: (T) -> String = { selector(it).sliceArticle() }
val comparator =
if (asc) {
compareBy(String.CASE_INSENSITIVE_ORDER, chained)
} else {
compareByDescending(String.CASE_INSENSITIVE_ORDER, chained)
}
return sortedWith(comparator)
protected inline fun <T : Music, K> compareByDynamic(
comparator: Comparator<in K>,
crossinline selector: (T) -> K
): Comparator<T> {
return if (isAscending) {
compareBy(comparator, selector)
} else {
compareByDescending(comparator, selector)
}
}
private fun <T : Music> Collection<T>.intSort(
asc: Boolean = isAscending,
selector: (T) -> Int,
): List<T> {
val comparator =
if (asc) {
compareBy(selector)
} else {
compareByDescending(selector)
class NameComparator<T : Music> : Comparator<T> {
override fun compare(a: T?, b: T?): Int {
if (a == null && b != null) return -1 // -1 -> a < b
if (a == null && b == null) return 0 // 0 -> 0 = b
if (a != null && b == null) return 1 // 1 -> a > b
return a!!.resolvedName
.sliceArticle()
.compareTo(b!!.resolvedName.sliceArticle(), ignoreCase = true)
}
}
class NullableComparator<T : Comparable<T>> : Comparator<T?> {
override fun compare(a: T?, b: T?): Int {
if (a == null && b != null) return -1 // -1 -> a < b
if (a == null && b == null) return 0 // 0 -> 0 = b
if (a != null && b == null) return 1 // 1 -> a > b
return a!!.compareTo(b!!)
}
}
/**
* Chains the given comparators together to form one comparator.
*
* Sorts often need to compare multiple things at once across several hierarchies, with this
* class doing such in a more efficient manner than resorting at multiple intervals or grouping
* items up. Comparators are checked from first to last, with the first comparator that returns a
* non-equal result being propagated upwards.
*/
class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
private val mComparators = comparators
override fun compare(a: T?, b: T?): Int {
for (comparator in mComparators) {
val result = comparator.compare(a, b)
if (result != 0) {
return result
}
}
return sortedWith(comparator)
return 0
}
}
companion object {
@ -229,15 +293,15 @@ sealed class Sort(open val isAscending: Boolean) {
* languages.
*/
fun String.sliceArticle(): String {
if (length > 5 && startsWith("the ", true)) {
if (length > 5 && startsWith("the ", ignoreCase = true)) {
return slice(4..lastIndex)
}
if (length > 4 && startsWith("an ", true)) {
if (length > 4 && startsWith("an ", ignoreCase = true)) {
return slice(3..lastIndex)
}
if (length > 3 && startsWith("a ", true)) {
if (length > 3 && startsWith("a ", ignoreCase = true)) {
return slice(2..lastIndex)
}

View file

@ -30,7 +30,7 @@
<dimen name="elevation_small">2dp</dimen>
<dimen name="elevation_normal">4dp</dimen>
<dimen name="fast_scroll_popup_min_width">80dp</dimen>
<dimen name="fast_scroll_popup_min_width">78dp</dimen>
<dimen name="fast_scroll_popup_min_height">64dp</dimen>
<dimen name="fast_scroll_popup_padding_start">@dimen/spacing_medium</dimen>
<dimen name="fast_scroll_popup_padding_end">28dp</dimen>