music: add date class

Add a new Date class to represent both years and more fine-grained
dates extracted using the ExoPlayer metadata system.

In-app, the year is still shown, but sorting will use the new precision
when present. The MediaSession will also post an RFC 3339 formatted
date with this new precision, as the MediaSession documentation states
I should. No clue if the latter will cause any bugs with naive metadata
UIs in other apps.

Resolves #159.
This commit is contained in:
OxygenCobalt 2022-07-15 10:23:01 -06:00
parent 7833ec4460
commit d4f74784ba
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 223 additions and 97 deletions

View file

@ -6,6 +6,7 @@
- Added option to ignore `MediaStore` tags, allowing more correct metadata
at the cost of longer loading times
- Added support for sort tags [#172, dependent on this feature]
- Added support for date tags, including more fine-grained dates [#159, dependent on this feature]
- Added Last Added sorting
## 2.5.0

View file

@ -56,7 +56,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
// --- SUPPORT ---

View file

@ -126,7 +126,8 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
text =
context.getString(
R.string.fmt_three,
item.year?.toString() ?: context.getString(R.string.def_date),
item.date?.let { context.getString(R.string.fmt_number, it.year) }
?: context.getString(R.string.def_date),
context.getPluralSafe(R.plurals.fmt_song_count, item.songs.size),
item.durationSecs.formatDuration(false))
}
@ -150,7 +151,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.artist.rawName == newItem.artist.rawName &&
oldItem.year == newItem.year &&
oldItem.date == newItem.date &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationSecs == newItem.durationSecs
}

View file

@ -27,6 +27,7 @@ import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveYear
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
import org.oxycblt.auxio.ui.recycler.BindingViewHolder
import org.oxycblt.auxio.ui.recycler.Item
@ -166,12 +167,7 @@ private constructor(
override fun bind(item: Album, listener: MenuItemListener) {
binding.parentImage.bind(item)
binding.parentName.textSafe = item.resolveName(binding.context)
binding.parentInfo.textSafe =
if (item.year != null) {
binding.context.getString(R.string.fmt_number, item.year)
} else {
binding.context.getString(R.string.def_date)
}
binding.parentInfo.textSafe = item.date.resolveYear(binding.context)
binding.root.apply {
setOnClickListener { listener.onItemClick(item) }
@ -195,7 +191,7 @@ private constructor(
val DIFFER =
object : SimpleItemCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.year == newItem.year
oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
}
}
}

View file

@ -23,6 +23,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.resolveYear
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.AlbumViewHolder
@ -64,7 +65,7 @@ class AlbumListFragment : HomeListFragment<Album>() {
is Sort.Mode.ByArtist -> album.artist.sortName?.run { first().uppercase() }
// Year -> Use Full Year
is Sort.Mode.ByYear -> album.year?.toString()
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationSecs.formatDuration(false)

View file

@ -71,7 +71,7 @@ class SongListFragment : HomeListFragment<Song>() {
is Sort.Mode.ByAlbum -> song.album.sortName?.run { first().uppercase() }
// Year -> Use Full Year
is Sort.Mode.ByYear -> song.album.year?.toString()
is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationSecs.formatDuration(false)

View file

@ -22,8 +22,14 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.inRangeOrNull
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS ---
@ -84,7 +90,7 @@ data class Song(
/** The disc number of this song, null if there isn't any. */
val disc: Int?,
/** Internal field. Do not use. */
val _year: Int?,
val _date: Date?,
/** Internal field. Do not use. */
val _albumName: String,
/** Internal field. Do not use. */
@ -164,9 +170,11 @@ data class Song(
val _artistGroupingSortName: String?
get() =
// Only use the album artist sort name if we have one, otherwise ignore it.
_albumArtistName?.let { _albumArtistSortName } ?: _artistName?.let { _artistSortName }
/** Internal field. Do not use. */
if (_albumArtistName != null) {
_albumArtistSortName
} else {
_artistSortName
}
/** Internal field. Do not use. */
val _isMissingAlbum: Boolean
@ -193,8 +201,7 @@ data class Song(
data class Album(
override val rawName: String,
override val rawSortName: String?,
/** The latest year of the songs in this album. Null if none of the songs had metadata. */
val year: Int?,
val date: Date?,
/** The URI for the cover art corresponding to this album. */
val albumCoverUri: Uri,
/** The songs of this album. */
@ -214,7 +221,7 @@ data class Album(
get() {
var result = rawName.hashCode().toLong()
result = 31 * result + artist.rawName.hashCode()
result = 31 * result + (year ?: 0)
result = 31 * result + (date?.year ?: 0)
return result
}
@ -281,3 +288,120 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
}
/**
* An ISO-8601/RFC 3339 Date.
*
* Unlike a typical Date within the standard library, this class is simply a 1:1 mapping between
* the tag date format of ID3v2 and (presumably) the Vorbis date format, implementing only format
* validation and excluding advanced or locale-specific date functionality..
*
* The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually
* make sense in a calendar, due to bad tagging, locale-specific issues, or simply from the
* limited nature of tag formats. Thus, it's better to use an analogous data structure that
* will not mangle or reject valid-ish dates.
*
* Date instances are immutable and their internal implementation is hidden. To instantiate one,
* use [fromYear] or [parseTimestamp]. The string representation of a Date is RFC 3339, with
* granular position depending on the presence of particular tokens.
*
* Please, *Do not use this for anything important related to time.* I cannot stress this enough.
* This class will blow up if you try to do that.
*
* @author OxygenCobalt
*/
class Date private constructor(private val tokens: List<Int>) : Comparable<Date> {
init {
if (BuildConfig.DEBUG) {
// Last-ditch sanity check to catch format bugs that might slip through
check(tokens.size in 1..6) { "There must be 1-6 date tokens" }
check(tokens.slice(0..min(tokens.lastIndex, 2)).all { it > 0 }) {
"All date tokens must be non-zero "
}
check(tokens.slice(1..tokens.lastIndex).all { it < 100 }) {
"All non-year tokens must be two digits"
}
}
}
val year: Int
get() = tokens[0]
private val month: Int?
get() = tokens.getOrNull(1)
private val day: Int?
get() = tokens.getOrNull(2)
private val hour: Int?
get() = tokens.getOrNull(3)
private val minute: Int?
get() = tokens.getOrNull(4)
private val second: Int?
get() = tokens.getOrNull(5)
fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year)
override fun hashCode() = tokens.hashCode()
override fun equals(other: Any?) = other is Date && tokens == other.tokens
override fun compareTo(other: Date): Int {
val comparator = Sort.Mode.NullableComparator.INT
for (i in 0..(max(tokens.lastIndex, other.tokens.lastIndex))) {
val result = comparator.compare(tokens.getOrNull(i), other.tokens.getOrNull(i))
if (result != 0) {
return result
}
}
return 0
}
override fun toString() = StringBuilder().appendDate().toString()
private fun StringBuilder.appendDate(): StringBuilder {
// I assume RFC 3339 allows partial precision, i.e YYYY-MM, but I'm not sure.
append(year.toFixedString(4))
append("-${(month ?: return this).toFixedString(2)}")
append("-${(day ?: return this).toFixedString(2)}")
append("T${(hour ?: return this).toFixedString(2)}")
append(":${(minute ?: return this).toFixedString(2)}")
append(":${(second ?: return this).toFixedString(2)}Z")
return this
}
private fun Int.toFixedString(len: Int) = toString().padStart(len, '0')
companion object {
private val ISO8601_REGEX =
Regex(
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$""")
fun fromYear(year: Int) = year.nonZeroOrNull()?.let { Date(listOf(it)) }
fun parseTimestamp(timestamp: String): Date? {
val groups = (ISO8601_REGEX.matchEntire(timestamp) ?: return null).groupValues
val tokens = mutableListOf<Int>()
populateTokens(groups, tokens)
if (tokens.isEmpty()) {
return null
}
return Date(tokens)
}
private fun populateTokens(groups: List<String>, tokens: MutableList<Int>) {
tokens.add(groups.getOrNull(1)?.toIntOrNull()?.nonZeroOrNull() ?: return)
tokens.add(groups.getOrNull(3)?.toIntOrNull()?.inRangeOrNull(1..12) ?: return)
tokens.add(groups.getOrNull(5)?.toIntOrNull()?.inRangeOrNull(1..31) ?: return)
tokens.add(groups.getOrNull(7)?.toIntOrNull()?.inRangeOrNull(0..23) ?: return)
tokens.add(groups.getOrNull(9)?.toIntOrNull()?.inRangeOrNull(0..59) ?: return)
tokens.add(groups.getOrNull(11)?.toIntOrNull()?.inRangeOrNull(0..59) ?: return)
}
}
}

View file

@ -19,10 +19,13 @@ package org.oxycblt.auxio.music
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.nonZeroOrNull
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
fun ContentResolver.queryCursor(
@ -68,27 +71,21 @@ fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
*/
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
/**
* Parse out a plain number from a string. Values of 0 will be ignored under the assumption that
* they are invalid.
*/
fun String.parseNum() = toIntOrNull()?.nonZeroOrNull()
/**
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
*/
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/**
* Parse out the year field from a (presumably) ISO-8601-like date. This differs across tag formats
* and has no real consistency, but it's assumed that most will format granular dates as YYYY-MM-DD
* (...) and thus we can parse the year out by splitting at the first -. Values of 0 will be ignored
* under the assumption that they are invalid.
*/
fun String.parseIso8601Year() = split('-', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/** Parse a plain year from the field into a [Date]. */
fun String.parseYear() = toIntOrNull()?.let(Date::fromYear)
private fun Int.nonZeroOrNull() = if (this > 0) this else null
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
fun String.parseTimestamp() = Date.parseTimestamp(this)
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
fun Date?.resolveYear(context: Context) =
this?.resolveYear(context) ?: context.getString(R.string.def_date)
/**
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
@ -124,7 +121,7 @@ private fun String.parseId3v1Genre(): String? =
}
private fun String.parseId3v2Genre(): String? {
val groups = (GENRE_RE.matchEntire(this) ?: return null).groups
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
val genres = mutableSetOf<String>()
// ID3v2 genres are far more complex and require string grokking to properly implement.
@ -133,9 +130,9 @@ private fun String.parseId3v2Genre(): String? {
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
// ID3v1 tags.
val genreIds = groups[1]
if (genreIds != null && genreIds.value.isNotEmpty()) {
val ids = genreIds.value.substring(1).split(")(")
val genreIds = groups.getOrNull(1)
if (genreIds != null && genreIds.isNotEmpty()) {
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
for (id in ids) {
id.parseId3v1Genre()?.let(genres::add)
}
@ -143,12 +140,12 @@ private fun String.parseId3v2Genre(): String? {
// Case 2: Genre names as a normal string. The only case we have to look out for are
// escaped strings formatted as ((genre).
val genreName = groups[3]
if (genreName != null && genreName.value.isNotEmpty()) {
if (genreName.value.startsWith("((")) {
genres.add(genreName.value.substring(1))
val genreName = groups.getOrNull(3)
if (genreName != null && genreName.isNotEmpty()) {
if (genreName.startsWith("((")) {
genres.add(genreName.substring(1))
} else {
genres.add(genreName.value)
genres.add(genreName)
}
}

View file

@ -27,9 +27,9 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.music.parseId3GenreName
import org.oxycblt.auxio.music.parseIso8601Year
import org.oxycblt.auxio.music.parseNum
import org.oxycblt.auxio.music.parsePositionNum
import org.oxycblt.auxio.music.parseTimestamp
import org.oxycblt.auxio.music.parseYear
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -224,10 +224,10 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(tags["TDOR"]?.parseIso8601Year()
?: tags["TDRC"]?.parseIso8601Year() ?: tags["TDRL"]?.parseIso8601Year()
?: tags["TORY"]?.parseNum() ?: tags["TYER"]?.parseNum())
?.let { audio.year = it }
(tags["TDOR"]?.parseTimestamp()
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
?: tags["TORY"]?.parseYear() ?: tags["TYER"]?.parseYear())
?.let { audio.date = it }
// (Sort) Album
tags["TALB"]?.let { audio.album = it }
@ -250,11 +250,11 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
tags["TITLE"]?.let { audio.title = it }
tags["TITLESORT"]?.let { audio.sortTitle = it }
// Track. Probably not NN/TT, as TOTALTRACKS handles totals.
tags["TRACKNUMBER"]?.parseNum()?.let { audio.track = it }
// Track
tags["TRACKNUMBER"]?.parsePositionNum()?.let { audio.track = it }
// Disc. Probably not NN/TT, as TOTALDISCS handles totals.
tags["DISCNUMBER"]?.parseNum()?.let { audio.disc = it }
// Disc
tags["DISCNUMBER"]?.parsePositionNum()?.let { audio.disc = it }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -262,9 +262,9 @@ class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// tag that android supports, so it must be 15 years old or more!)
(tags["ORIGINALDATE"]?.parseIso8601Year()
?: tags["DATE"]?.parseIso8601Year() ?: tags["YEAR"]?.parseNum())
?.let { audio.year = it }
(tags["ORIGINALDATE"]?.parseTimestamp()
?: tags["DATE"]?.parseTimestamp() ?: tags["YEAR"]?.parseYear())
?.let { audio.date = it }
// (Sort) Album
tags["ALBUM"]?.let { audio.album = it }

View file

@ -308,13 +308,13 @@ class Indexer {
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives.
val templateSong =
albumSongs.maxWith(compareBy(Sort.Mode.NULLABLE_INT_COMPARATOR) { it._year })
albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date })
albums.add(
Album(
rawName = templateSong._albumName,
rawSortName = templateSong._albumSortName,
year = templateSong._year,
date = templateSong._date,
albumCoverUri = templateSong._albumCoverUri,
songs = entry.value,
_artistGroupingName = templateSong._artistGroupingName,
@ -344,7 +344,7 @@ class Indexer {
albums = entry.value))
}
`logD`("Successfully built ${artists.size} artists")
logD("Successfully built ${artists.size} artists")
return artists
}

View file

@ -27,6 +27,7 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.music.MimeType
import org.oxycblt.auxio.music.Path
@ -292,7 +293,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.displayName = cursor.getStringOrNull(displayNameIndex)
audio.duration = cursor.getLong(durationIndex)
audio.year = cursor.getIntOrNull(yearIndex)
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::fromYear)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
@ -307,11 +308,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
// it's easier to handle later.
audio.artist =
cursor.getString(artistIndex).run {
if (this != MediaStore.UNKNOWN_STRING) {
this
} else {
null
}
if (this != MediaStore.UNKNOWN_STRING) this else null
}
// The album artist field is nullable and never has placeholder values.
@ -338,7 +335,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
var duration: Long? = null,
var track: Int? = null,
var disc: Int? = null,
var year: Int? = null,
var date: Date? = null,
var album: String? = null,
var sortAlbum: String? = null,
var albumId: Long? = null,
@ -370,7 +367,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_year = year,
_date = date,
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumSortName = sortAlbum,
_albumCoverUri =

View file

@ -129,16 +129,16 @@ class MediaSessionComponent(
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs))
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs)
if (song.track != null) {
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, song.track.toLong())
song.track?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
}
if (song.disc != null) {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, song.disc.toLong())
song.disc?.let {
builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong())
}
if (song.album.year != null) {
builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, song.album.year.toString())
song.album.date?.let {
builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString())
}
// Cover loading is a mess. Android expects you to provide a clean, easy URI for it to

View file

@ -23,6 +23,7 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
@ -143,8 +144,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(ascending, BasicComparator.ALBUM) { it.album },
compareBy(NULLABLE_INT_COMPARATOR) { it.disc },
compareBy(NULLABLE_INT_COMPARATOR) { it.track },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@ -159,16 +160,16 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(ascending, BasicComparator.ARTIST) { it.album.artist },
compareByDescending(NULLABLE_INT_COMPARATOR) { it.album.year },
compareByDescending(NullableComparator.DATE) { it.album.date },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NULLABLE_INT_COMPARATOR) { it.disc },
compareBy(NULLABLE_INT_COMPARATOR) { it.track },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
override fun getAlbumComparator(ascending: Boolean): Comparator<Album> =
MultiComparator(
compareByDynamic(ascending, BasicComparator.ARTIST) { it.artist },
compareByDescending(NULLABLE_INT_COMPARATOR) { it.year },
compareByDescending(NullableComparator.DATE) { it.date },
compareBy(BasicComparator.ALBUM))
}
@ -182,15 +183,15 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(ascending, NULLABLE_INT_COMPARATOR) { it.album.year },
compareByDynamic(ascending, NullableComparator.DATE) { it.album.date },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NULLABLE_INT_COMPARATOR) { it.disc },
compareBy(NULLABLE_INT_COMPARATOR) { it.track },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
override fun getAlbumComparator(ascending: Boolean): Comparator<Album> =
MultiComparator(
compareByDynamic(ascending, NULLABLE_INT_COMPARATOR) { it.year },
compareByDynamic(ascending, NullableComparator.DATE) { it.date },
compareBy(BasicComparator.ALBUM))
}
@ -255,8 +256,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(ascending, NULLABLE_INT_COMPARATOR) { it.disc },
compareBy(NULLABLE_INT_COMPARATOR) { it.track },
compareByDynamic(ascending, NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@ -273,8 +274,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(ascending: Boolean): Comparator<Song> =
MultiComparator(
compareBy(NULLABLE_INT_COMPARATOR) { it.disc },
compareByDynamic(ascending, NULLABLE_INT_COMPARATOR) { it.track },
compareBy(NullableComparator.INT) { it.disc },
compareByDynamic(ascending, NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@ -370,19 +371,23 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
}
}
companion object {
// Exposed as Indexer relies on it at points
val NULLABLE_INT_COMPARATOR =
Comparator<Int?> { a, b ->
when {
a != null && b != null -> a.compareTo(b)
a == null && b != null -> -1 // a < b
a == null && b == null -> 0 // a = b
a != null && b == null -> 1 // a < b
else -> error("Unreachable")
}
class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
override fun compare(a: T?, b: T?) =
when {
a != null && b != null -> a.compareTo(b)
a == null && b != null -> -1 // a < b
a == null && b == null -> 0 // a = b
a != null && b == null -> 1 // a < b
else -> error("Unreachable")
}
companion object {
val INT = NullableComparator<Int>()
val DATE = NullableComparator<Date>()
}
}
companion object {
fun fromItemId(@IdRes itemId: Int) =
when (itemId) {
ByName.itemId -> ByName

View file

@ -43,6 +43,10 @@ fun <T> unlikelyToBeNull(value: T?) =
value!!
}
fun Int.nonZeroOrNull() = if (this > 0) this else null
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
/**
* Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--