music: handle numbers when sorting

Now sort by logical numeric order with music names.

This is a nice QoL improvement.

Resolves #394.
This commit is contained in:
Alexander Capehart 2023-03-19 14:08:37 -06:00
parent 8ea637ff05
commit a485ebf1fe
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 94 additions and 69 deletions

View file

@ -52,9 +52,6 @@ import org.oxycblt.auxio.util.*
* A [ListFragment] that shows information about an [Album]. * A [ListFragment] that shows information about an [Album].
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Split up list and header adapters, and then work from there. Header item works fine. Make
* sure that other pos-dependent code functions
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class AlbumDetailFragment : class AlbumDetailFragment :

View file

@ -94,11 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) { return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> album.sortName?.thumbString
// By Artist -> Use name of first artist // By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd) // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) } is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }

View file

@ -93,7 +93,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) { return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> artist.sortName?.thumbString
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)

View file

@ -92,7 +92,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> genre.sortName?.thumbString
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)

View file

@ -100,15 +100,13 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects. // based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) { return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() } is Sort.Mode.ByName -> song.sortName?.thumbString
// Artist -> Use name of first artist // Artist -> Use name of first artist
is Sort.Mode.ByArtist -> is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Album -> Use Album Name // Album -> Use Album Name
is Sort.Mode.ByAlbum -> is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year // Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext()) is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())

View file

@ -203,7 +203,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the item's name. * Sort by the item's name.
* *
* @see Music.collationKey * @see Music.sortName
*/ */
object ByName : Mode() { object ByName : Mode() {
override val intCode: Int override val intCode: Int
@ -248,7 +248,7 @@ data class Sort(val mode: Mode, val direction: Direction) {
/** /**
* Sort by the [Artist] name of an item. Only available for [Song] and [Album]. * Sort by the [Artist] name of an item. Only available for [Song] and [Album].
* *
* @see Artist.collationKey * @see Artist.sortName
*/ */
object ByArtist : Mode() { object ByArtist : Mode() {
override val intCode: Int override val intCode: Int
@ -536,8 +536,8 @@ data class Sort(val mode: Mode, val direction: Direction) {
*/ */
private class BasicComparator<T : Music> private constructor() : Comparator<T> { private class BasicComparator<T : Music> private constructor() : Comparator<T> {
override fun compare(a: T, b: T): Int { override fun compare(a: T, b: T): Int {
val aKey = a.collationKey val aKey = a.sortName
val bKey = b.collationKey val bKey = b.sortName
return when { return when {
aKey != null && bKey != null -> aKey.compareTo(bKey) aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b aKey == null && bKey != null -> -1 // a < b

View file

@ -23,6 +23,7 @@ import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
@ -34,6 +35,7 @@ import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.storage.Path import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.toUuidOrNull
/** /**
@ -74,15 +76,11 @@ sealed interface Music : Item {
val rawSortName: String? val rawSortName: String?
/** /**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a * A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
* semantically-correct manner. Will be null if the item has no name. * sorting in the context of music. This should be preferred over [rawSortName] in most cases.
* * Null if there are no [rawName] or [rawSortName] values to build on.
* The key will have the following attributes:
* - If [rawSortName] is present, this key will be derived from it. Otherwise [rawName] is used.
* - If the string begins with an article, such as "the", it will be stripped, as is usually
* convention for sorting media. This is not internationalized.
*/ */
val collationKey: CollationKey? val sortName: SortName?
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
@ -359,6 +357,78 @@ interface Genre : MusicParent {
val durationMs: Long val durationMs: Long
} }
/**
* A black-box datatype for a variation of music names that is suitable for music-oriented sorting.
* It will automatically handle articles like "The" and numeric components like "An".
* @author Alexander Capehart (OxygenCobalt)
*/
class SortName(name: String, musicSettings: MusicSettings) : Comparable<SortName> {
private val number: Int?
private val collationKey: CollationKey
val thumbString: String?
init {
var sortName = name
if (musicSettings.automaticSortNames) {
sortName =
sortName.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
}
// Parse out numeric portions of the title and use those for sorting, if applicable.
val numericEnd = sortName.indexOfFirst { !it.isDigit() }
when (numericEnd) {
// No numeric component.
0 -> number = null
// Whole title is numeric.
-1 -> {
number = sortName.toIntOrNull()
sortName = ""
}
// Part of the title is numeric.
else -> {
number = sortName.slice(0 until numericEnd).toIntOrNull()
sortName = sortName.slice(numericEnd until sortName.length)
}
}
collationKey = COLLATOR.getCollationKey(sortName)
// Keep track of a string to use in the thumb view.
// TODO: This needs to be moved elsewhere.
thumbString = (number?.toString() ?: collationKey?.run { sourceString.first().uppercase() })
}
override fun toString(): String = number?.toString() ?: collationKey.sourceString
override fun compareTo(other: SortName) =
when {
number != null && other.number != null -> number.compareTo(other.number)
number != null && other.number == null -> -1 // a < b
number == null && other.number != null -> 1 // a > b
else -> collationKey.compareTo(other.collationKey)
}
override fun equals(other: Any?) =
other is SortName && number == other.number && collationKey == other.collationKey
override fun hashCode(): Int {
var hashCode = collationKey.hashCode()
if (number != null) hashCode = 31 * hashCode + number
return hashCode
}
private companion object {
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
}
}
/** /**
* Run [Music.resolveName] on each instance in the given list and concatenate them into a [String] * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
* in a localized manner. * in a localized manner.

View file

@ -21,17 +21,9 @@ package org.oxycblt.auxio.music.model
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType import org.oxycblt.auxio.music.metadata.ReleaseType
@ -72,7 +64,7 @@ class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song {
} }
override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" } override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" }
override val rawSortName = rawSong.sortName override val rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val track = rawSong.track override val track = rawSong.track
@ -248,7 +240,7 @@ class AlbumImpl(
} }
override val rawName = rawAlbum.name override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date }) override val dates = Date.Range.from(songs.mapNotNull { it.date })
@ -341,7 +333,7 @@ class ArtistImpl(
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name override val rawName = rawArtist.name
override val rawSortName = rawArtist.sortName override val rawSortName = rawArtist.sortName
override val collationKey = makeCollationKey(musicSettings) override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song> override val songs: List<Song>
@ -426,7 +418,7 @@ class GenreImpl(
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name override val rawName = rawGenre.name
override val rawSortName = rawName override val rawSortName = rawName
override val collationKey = makeCollationKey(musicSettings) override val sortName = (rawSortName ?: rawName)?.let { SortName(it, musicSettings) }
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
override val albums: List<Album> override val albums: List<Album>
@ -532,31 +524,3 @@ fun MessageDigest.update(n: Int?) {
update(0) update(0)
} }
} }
/** Cached collator instance re-used with [makeCollationKey]. */
private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
/**
* Provided implementation to create a [CollationKey] in the way described by [Music.collationKey].
* This should be used in all overrides of all [CollationKey].
*
* @param musicSettings [MusicSettings] required for user parsing configuration.
* @return A [CollationKey] that follows the specification described by [Music.collationKey].
*/
private fun Music.makeCollationKey(musicSettings: MusicSettings): CollationKey? {
var sortName = (rawSortName ?: rawName) ?: return null
if (musicSettings.automaticSortNames) {
sortName =
sortName.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
}
return COLLATOR.getCollationKey(sortName)
}

View file

@ -173,7 +173,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
fun from(parent: View) = fun from(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
// TODO: This is not good enough, I need to compare item indices as well.
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
} }

View file

@ -33,8 +33,6 @@ import org.oxycblt.auxio.util.logD
* such as an animation when lifting items. * such as an animation when lifting items.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Why is item movement so expensive???
*/ */
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
private var shouldLift = true private var shouldLift = true