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:
parent
8ea637ff05
commit
a485ebf1fe
10 changed files with 94 additions and 69 deletions
|
@ -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 :
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue