From a485ebf1fe46ed8d9e8d51ca4c47f0206c76de58 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 19 Mar 2023 14:08:37 -0600 Subject: [PATCH] music: handle numbers when sorting Now sort by logical numeric order with music names. This is a nice QoL improvement. Resolves #394. --- .../auxio/detail/AlbumDetailFragment.kt | 3 - .../auxio/home/list/AlbumListFragment.kt | 5 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 8 +- .../main/java/org/oxycblt/auxio/list/Sort.kt | 8 +- .../java/org/oxycblt/auxio/music/Music.kt | 86 +++++++++++++++++-- .../oxycblt/auxio/music/model/MusicImpl.kt | 46 ++-------- .../auxio/playback/queue/QueueAdapter.kt | 1 - .../auxio/playback/queue/QueueDragCallback.kt | 2 - 10 files changed, 94 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 05b8c534a..12f7098cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -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 : diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 316216455..b5b9135dd 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -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()) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index b73cbcbef..c6a58f594 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index 895c83643..3561abbb4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 8ea9db367..9dc512b99 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -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()) diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt index d53892946..ec64cdb3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -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 private constructor() : Comparator { 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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index b7ca91c14..e9c917c54 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 { + 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. diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt index 0966df7cb..682c011fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt @@ -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 @@ -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 @@ -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) -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 00915e3de..11137b7ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 50f648392..5b61eb7c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -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