music: switch sort names to collation
Use CollationKey when sorting music instead of String isntances. This makes sorting aware of accented characters while still preserving speed. I would ideally want to extend this to the search view too, but there is no contains command in CollationKey, so I must continue with the normalization method there.
This commit is contained in:
parent
855331aafa
commit
7bc9f4869b
11 changed files with 72 additions and 84 deletions
|
@ -5,6 +5,9 @@
|
|||
#### What's New
|
||||
- Reworked music hashing to be even more reliable (Will wipe playback state)
|
||||
|
||||
#### What's Improved
|
||||
- Sorting now takes accented characters into account
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed issue where the scroll popup would not display correctly in landscape mode [#230]
|
||||
- Fixed issue where the playback progress would continue in the notification even if
|
||||
|
|
|
@ -64,10 +64,10 @@ class AlbumListFragment : HomeListFragment<Album>() {
|
|||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> album.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// By Artist -> Use Artist Name
|
||||
is Sort.Mode.ByArtist -> album.artist.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByArtist -> album.artist.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
|
||||
|
|
|
@ -59,7 +59,7 @@ class ArtistListFragment : HomeListFragment<Artist>() {
|
|||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ARTISTS).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false)
|
||||
|
|
|
@ -59,7 +59,7 @@ class GenreListFragment : HomeListFragment<Genre>() {
|
|||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_GENRES).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> genre.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||
|
|
|
@ -74,13 +74,13 @@ class SongListFragment : HomeListFragment<Song>() {
|
|||
// based off the names of the parent objects and not the child objects.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS).mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Artist -> Use Artist Name
|
||||
is Sort.Mode.ByArtist -> song.album.artist.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByArtist -> song.album.artist.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Album -> Use Album Name
|
||||
is Sort.Mode.ByAlbum -> song.album.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByAlbum -> song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
|
||||
|
|
|
@ -32,6 +32,8 @@ import org.oxycblt.auxio.util.inRangeOrNull
|
|||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import java.security.MessageDigest
|
||||
import java.text.CollationKey
|
||||
import java.text.Collator
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
@ -50,27 +52,29 @@ sealed class Music : Item {
|
|||
abstract val rawSortName: String?
|
||||
|
||||
/**
|
||||
* The name of this item used for sorting.This should not be used outside of sorting and
|
||||
* fast-scrolling.
|
||||
*/
|
||||
val sortName: String?
|
||||
get() =
|
||||
rawSortName
|
||||
?: rawName?.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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
|
||||
* become Unknown Artist, (124) would become its proper genre name, etc.
|
||||
* Resolve a name from it's raw form to a form suitable to be shown in a UI.
|
||||
* Null values will be resolved into their string form with this function.
|
||||
*/
|
||||
abstract fun resolveName(context: Context): String
|
||||
|
||||
/**
|
||||
* A key used by the sorting system that takes into account the sort tags of this item,
|
||||
* any (english) articles that prefix the names, and collation rules. Lazily generated
|
||||
* since generating a collation key is non-trivial.
|
||||
*/
|
||||
val collationKey: CollationKey? by lazy {
|
||||
val sortName = (rawSortName ?: rawName)?.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
|
||||
}
|
||||
}
|
||||
|
||||
COLLATOR.getCollationKey(sortName)
|
||||
}
|
||||
|
||||
// Equality is based on UIDs, as some items (Especially artists) can have identical
|
||||
// properties (Name) yet non-identical UIDs due to MusicBrainz tags
|
||||
|
||||
|
@ -130,6 +134,12 @@ sealed class Music : Item {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val COLLATOR = Collator.getInstance().apply {
|
||||
strength = Collator.PRIMARY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -208,6 +218,8 @@ class Song constructor(raw: Raw) : Music() {
|
|||
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
|
||||
*/
|
||||
val individualArtistRawName: String?
|
||||
// Note: This is a getter since it relies on a parent value that will not be initialized
|
||||
// yet on creation.
|
||||
get() = artistName ?: album.artist.rawName
|
||||
|
||||
/**
|
||||
|
@ -263,8 +275,8 @@ class Song constructor(raw: Raw) : Music() {
|
|||
// by now.
|
||||
uid =
|
||||
UID.hashed(this::class) {
|
||||
update(rawName.lowercase())
|
||||
update(_rawAlbum.name.lowercase())
|
||||
update(rawName)
|
||||
update(_rawAlbum.name)
|
||||
update(_rawAlbum.date)
|
||||
|
||||
update(artistName)
|
||||
|
@ -579,26 +591,20 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
}
|
||||
}
|
||||
|
||||
val year: Int
|
||||
get() = tokens[0]
|
||||
val year = tokens[0]
|
||||
|
||||
/** Resolve the year field in a way suitable for the UI. */
|
||||
fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year)
|
||||
|
||||
private val month: Int?
|
||||
get() = tokens.getOrNull(1)
|
||||
private val month = tokens.getOrNull(1)
|
||||
|
||||
private val day: Int?
|
||||
get() = tokens.getOrNull(2)
|
||||
private val day = tokens.getOrNull(2)
|
||||
|
||||
private val hour: Int?
|
||||
get() = tokens.getOrNull(3)
|
||||
private val hour = tokens.getOrNull(3)
|
||||
|
||||
private val minute: Int?
|
||||
get() = tokens.getOrNull(4)
|
||||
private val minute = tokens.getOrNull(4)
|
||||
|
||||
private val second: Int?
|
||||
get() = tokens.getOrNull(5)
|
||||
private val second = tokens.getOrNull(5)
|
||||
|
||||
override fun hashCode() = tokens.hashCode()
|
||||
|
||||
|
|
|
@ -31,5 +31,3 @@ class CacheLayer {
|
|||
|
||||
fun maybePopulateCachedRaw(raw: Song.Raw) = false
|
||||
}
|
||||
|
||||
// TODO: Make raw naming consistent (always rawSong(s), not raw)
|
||||
|
|
|
@ -256,9 +256,9 @@ class Indexer {
|
|||
val songs = mutableSetOf<Song>()
|
||||
val rawSongs = mutableListOf<Song.Raw>()
|
||||
|
||||
metadataLayer.parse { raw ->
|
||||
songs.add(Song(raw))
|
||||
rawSongs.add(raw)
|
||||
metadataLayer.parse { rawSong ->
|
||||
songs.add(Song(rawSong))
|
||||
rawSongs.add(rawSong)
|
||||
|
||||
// Check if we got cancelled after every song addition.
|
||||
yield()
|
||||
|
|
|
@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent
|
|||
/** The notification responsible for showing the indexer state. */
|
||||
class IndexingNotification(private val context: Context) :
|
||||
ServiceNotification(context, INDEXER_CHANNEL) {
|
||||
private var lastUpdateTime: Int = -1
|
||||
private var lastUpdateTime = -1L
|
||||
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_indexer_24)
|
||||
|
@ -51,16 +51,19 @@ class IndexingNotification(private val context: Context) :
|
|||
when (indexing) {
|
||||
is Indexer.Indexing.Indeterminate -> {
|
||||
logD("Updating state to $indexing")
|
||||
lastUpdateTime = -1
|
||||
setContentText(context.getString(R.string.lng_indexing))
|
||||
setProgress(0, 0, true)
|
||||
return true
|
||||
}
|
||||
is Indexer.Indexing.Songs -> {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (lastUpdateTime != -1 && (now - lastUpdateTime) < 1500) {
|
||||
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastUpdateTime = SystemClock.elapsedRealtime()
|
||||
|
||||
// Only update the notification every two seconds to prevent rate-limiting.
|
||||
logD("Updating state to $indexing")
|
||||
setContentText(
|
||||
|
|
|
@ -157,44 +157,21 @@ class SearchViewModel(application: Application) :
|
|||
|
||||
private fun List<Genre>.filterGenresBy(value: String) = baseFilterBy(value) { false }
|
||||
|
||||
private inline fun <T : Music> List<T>.baseFilterBy(value: String, additional: (T) -> Boolean) =
|
||||
private inline fun <T : Music> List<T>.baseFilterBy(value: String, fallback: (T) -> Boolean) =
|
||||
filter {
|
||||
// The basic comparison is first by the *normalized* name, as that allows a
|
||||
// non-unicode search to match with some unicode characters. If that fails,
|
||||
// filter impls have fallback values, primarily around sort tags or file names.
|
||||
// non-unicode search to match with some unicode characters. In an ideal world, we
|
||||
// would just want to leverage CollationKey, but that is not designed for a contains
|
||||
// algorithm. If that fails, filter impls have fallback values, primarily around
|
||||
// sort tags or file names.
|
||||
it.resolveNameNormalized(application).contains(value, ignoreCase = true) ||
|
||||
additional(it)
|
||||
fallback(it)
|
||||
}
|
||||
.ifEmpty { null }
|
||||
|
||||
private fun Music.resolveNameNormalized(context: Context): String {
|
||||
// This method normalizes strings so that songs with accented characters will show
|
||||
// up in search even if the actual character was not inputted.
|
||||
// https://stackoverflow.com/a/32030586/14143986
|
||||
|
||||
// Normalize with NFKD [Meaning that symbols with identical meanings will be turned into
|
||||
// their letter variants].
|
||||
val norm = Normalizer.normalize(resolveName(context), Normalizer.Form.NFKD)
|
||||
|
||||
// Normalizer doesn't exactly finish the job though. We have to rebuild all the codepoints
|
||||
// in the string and remove the hidden characters that were added by Normalizer.
|
||||
var idx = 0
|
||||
val sb = StringBuilder()
|
||||
|
||||
while (idx < norm.length) {
|
||||
val cp = norm.codePointAt(idx)
|
||||
idx += Character.charCount(cp)
|
||||
|
||||
when (Character.getType(cp)) {
|
||||
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added
|
||||
// by normalizer
|
||||
6,
|
||||
8 -> continue
|
||||
else -> sb.appendCodePoint(cp)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
return NORMALIZATION_SANITIZE_REGEX.replace(norm, "")
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -208,4 +185,8 @@ class SearchViewModel(application: Application) :
|
|||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -369,16 +369,13 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
|||
}
|
||||
|
||||
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
|
||||
// TODO: Perhaps I should leverage collator?
|
||||
|
||||
override fun compare(a: T, b: T): Int {
|
||||
val aSortName = a.sortName
|
||||
val bSortName = b.sortName
|
||||
val aKey = a.collationKey
|
||||
val bKey = b.collationKey
|
||||
return when {
|
||||
aSortName != null && bSortName != null ->
|
||||
aSortName.compareTo(bSortName, ignoreCase = true)
|
||||
aSortName == null && bSortName != null -> -1 // a < b
|
||||
aSortName == null && bSortName == null -> 0 // a = b
|
||||
aKey != null && bKey != null -> aKey.compareTo(bKey)
|
||||
aKey == null && bKey != null -> -1 // a < b
|
||||
aKey == null && bKey == null -> 0 // a = b
|
||||
else -> 1 // a < b
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue