music: add support for album date ranges

Add support for albums to have a range of dates.

Often compilation albums will have Songs released in different months
or years, so it makes some sense to show a date range rather than just
the ealiest date.

The only point at which the earliest date is still shown is in the home
view's popup, as maxiumum dates in a date range are not sorted by, and
so showing it doesn't make sense.
This commit is contained in:
Alexander Capehart 2022-12-30 16:07:17 -07:00
parent 4533251efd
commit cf6e7a5f0d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 79 additions and 31 deletions

View file

@ -2,6 +2,9 @@
## dev
#### What's New
- Added support for album date ranges (ex. 2010 - 2013)
#### What's Improved
- Formalized whitespace handling

View file

@ -142,7 +142,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date)
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
@ -170,7 +170,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) &&
oldItem.date == newItem.date &&
oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
oldItem.type == newItem.type

View file

@ -189,7 +189,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
binding.parentName.text = album.resolveName(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date)
album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -217,7 +218,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
}
}
}

View file

@ -94,8 +94,8 @@ class AlbumListFragment :
is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
// 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()) }
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)

View file

@ -103,7 +103,7 @@ class SongListFragment :
song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.date?.resolveDate(requireContext())
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)

View file

@ -610,11 +610,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName
/**
* The earliest [Date] this album was released. Will be null if no valid date was present in the
* metadata of any [Song]
*/
val date: Date? // TODO: Date ranges?
/** The [DateRange] that [Song]s in the [Album] were released. */
val dates: DateRange? = DateRange.from(songs.mapNotNull { it.date })
/**
* The [Type] of this album, signifying the type of release it actually is. Defaults to
@ -634,31 +631,18 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
val dateAdded: Long
init {
var earliestDate: Date? = null
var totalDuration: Long = 0
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in songs) {
song._link(this)
if (song.date != null) {
// Since we can't really assign a maximum value for dates, we instead
// just check if the current earliest date doesn't exist and fill it
// in with the current song if that's the case.
if (earliestDate == null || song.date < earliestDate) {
earliestDate = song.date
}
}
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
date = earliestDate
durationMs = totalDuration
dateAdded = earliestDateAdded
}
@ -1385,6 +1369,63 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
}
}
/**
* A range of [Date]s. This is used in contexts where the [Date] of an item is derived from several
* sub-items and thus can have a "range" of release dates.
* @param min The earliest [Date] in the range.
* @param max The latest [Date] in the range. May be the same as [min].
* @author Alexander Capehart
*/
class DateRange private constructor(val min: Date, val max: Date) : Comparable<DateRange> {
/**
* Resolve this instance into a human-readable date range.
* @param context [Context] required to get human-readable names.
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
* formatted name of the minimum [Date] will be returned.
*/
fun resolveDate(context: Context) =
if (min != max) {
context.getString(
R.string.fmt_date_range, min.resolveDate(context), max.resolveDate(context))
} else {
min.resolveDate(context)
}
override fun compareTo(other: DateRange): Int {
return min.compareTo(other.min)
}
companion object {
/**
* Create a [DateRange] from the given list of [Date]s.
* @param dates The [Date]s to use.
* @return A [DateRange] based on the minimum and maximum [Date]s from [dates], or a
* [DateRange] with a single minimum. If no [Date]s were given, null is returned.
*/
fun from(dates: List<Date>): DateRange? {
if (dates.isEmpty()) {
// Nothing to do.
return null
}
// Simultaneously find the minimum and maximum values in the given range.
// If this list has only one item, then that one date is the minimum and maximum.
var min = dates.first()
var max = min
for (i in 1..dates.lastIndex) {
if (dates[i] < min) {
min = dates[i]
}
if (dates[i] > max) {
max = dates[i]
}
}
return DateRange(min, max)
}
}
}
// --- MUSIC UID CREATION UTILITIES ---
/**

View file

@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.album.date },
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
@ -241,14 +241,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE) { it.date },
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
/**
* Sort by the [Date] of an item. Only available for [Song] and [Album].
* @see Song.date
* @see Album.date
* @see Album.dates
*/
object ByDate : Mode() {
override val intCode: Int
@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc },
compareBy(NullableComparator.INT) { it.track },
@ -267,7 +267,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
@ -366,7 +366,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
/**
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
* @see Song.dateAdded
* @see Album.date
* @see Album.dates
*/
object ByDateAdded : Mode() {
override val intCode: Int
@ -545,6 +545,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val LONG = NullableComparator<Long>()
/** A re-usable instance configured for [Date]s. */
val DATE = NullableComparator<Date>()
/** A re-usable instance configured for [DateRange]s. */
val DATE_RANGE = NullableComparator<DateRange>()
}
}

View file

@ -7,6 +7,7 @@
<string name="fmt_two" translatable="false">%1$s • %2$s</string>
<string name="fmt_three" translatable="false">%1$s • %2$s • %3$s</string>
<string name="fmt_number" translatable="false">%d</string>
<string name="fmt_date_range" translatable="false">%s - %s</string>
<string name="fmt_path">%1$s/%2$s</string>
<!-- Codec Namespace | Format names -->