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].
*
* @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
class AlbumDetailFragment :

View file

@ -94,11 +94,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
// 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
is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByArtist -> album.artists[0].sortName?.thumbString
// 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()) }

View file

@ -93,7 +93,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
// 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
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.
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
// 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
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.
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// 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
is Sort.Mode.ByArtist ->
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByArtist -> song.album.artists[0].sortName?.thumbString
// Album -> Use Album Name
is Sort.Mode.ByAlbum ->
song.album.collationKey?.run { sourceString.first().uppercase() }
is Sort.Mode.ByAlbum -> song.album.sortName?.thumbString
// Year -> Use Full Year
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.
*
* @see Music.collationKey
* @see Music.sortName
*/
object ByName : Mode() {
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].
*
* @see Artist.collationKey
* @see Artist.sortName
*/
object ByArtist : Mode() {
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> {
override fun compare(a: T, b: T): Int {
val aKey = a.collationKey
val bKey = b.collationKey
val aKey = a.sortName
val bKey = b.sortName
return when {
aKey != null && bKey != null -> aKey.compareTo(bKey)
aKey == null && bKey != null -> -1 // a < b

View file

@ -23,6 +23,7 @@ import android.net.Uri
import android.os.Parcelable
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import java.util.UUID
import kotlin.math.max
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.Path
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.toUuidOrNull
/**
@ -74,15 +76,11 @@ sealed interface Music : Item {
val rawSortName: String?
/**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a
* semantically-correct manner. Will be null if the item has no name.
*
* 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.
* A black-box value derived from [rawSortName] and [rawName] that can be used for user-friendly
* 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.
*/
val collationKey: CollationKey?
val sortName: SortName?
/**
* A unique identifier for a piece of music.
@ -359,6 +357,78 @@ interface Genre : MusicParent {
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]
* in a localized manner.

View file

@ -21,17 +21,9 @@ package org.oxycblt.auxio.music.model
import android.content.Context
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort
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.MusicMode
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.metadata.Disc
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 rawSortName = rawSong.sortName
override val collationKey = makeCollationKey(musicSettings)
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val track = rawSong.track
@ -248,7 +240,7 @@ class AlbumImpl(
}
override val rawName = rawAlbum.name
override val rawSortName = rawAlbum.sortName
override val collationKey = makeCollationKey(musicSettings)
override val sortName = SortName((rawSortName ?: rawName), musicSettings)
override fun resolveName(context: Context) = rawName
override val dates = Date.Range.from(songs.mapNotNull { it.date })
@ -341,7 +333,7 @@ class ArtistImpl(
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
override val rawName = rawArtist.name
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 val songs: List<Song>
@ -426,7 +418,7 @@ class GenreImpl(
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
override val rawName = rawGenre.name
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 val albums: List<Album>
@ -532,31 +524,3 @@ fun MessageDigest.update(n: Int?) {
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) =
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. */
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.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Why is item movement so expensive???
*/
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
private var shouldLift = true