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].
|
* 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 :
|
||||||
|
|
|
@ -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()) }
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue