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:
parent
4533251efd
commit
cf6e7a5f0d
8 changed files with 79 additions and 31 deletions
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## dev
|
## dev
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Added support for album date ranges (ex. 2010 - 2013)
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Formalized whitespace handling
|
- Formalized whitespace handling
|
||||||
|
|
||||||
|
|
|
@ -142,7 +142,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
||||||
// Date, song count, and duration map to the info text
|
// Date, song count, and duration map to the info text
|
||||||
binding.detailInfo.apply {
|
binding.detailInfo.apply {
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// 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 songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||||
val duration = album.durationMs.formatDurationMs(true)
|
val duration = album.durationMs.formatDurationMs(true)
|
||||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
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) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.areArtistContentsTheSame(newItem) &&
|
oldItem.areArtistContentsTheSame(newItem) &&
|
||||||
oldItem.date == newItem.date &&
|
oldItem.dates == newItem.dates &&
|
||||||
oldItem.songs.size == newItem.songs.size &&
|
oldItem.songs.size == newItem.songs.size &&
|
||||||
oldItem.durationMs == newItem.durationMs &&
|
oldItem.durationMs == newItem.durationMs &&
|
||||||
oldItem.type == newItem.type
|
oldItem.type == newItem.type
|
||||||
|
|
|
@ -189,7 +189,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
binding.parentName.text = album.resolveName(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// 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) {
|
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||||
|
@ -217,7 +218,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleItemCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
|
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -94,8 +94,8 @@ class AlbumListFragment :
|
||||||
is Sort.Mode.ByArtist ->
|
is Sort.Mode.ByArtist ->
|
||||||
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||||
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
|
is Sort.Mode.ByDate -> album.dates?.run { min.resolveDate(requireContext()) }
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||||
|
|
|
@ -103,7 +103,7 @@ class SongListFragment :
|
||||||
song.album.collationKey?.run { sourceString.first().uppercase() }
|
song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// 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
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||||
|
|
|
@ -610,11 +610,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
|
||||||
override val collationKey = makeCollationKeyImpl()
|
override val collationKey = makeCollationKeyImpl()
|
||||||
override fun resolveName(context: Context) = rawName
|
override fun resolveName(context: Context) = rawName
|
||||||
|
|
||||||
/**
|
/** The [DateRange] that [Song]s in the [Album] were released. */
|
||||||
* The earliest [Date] this album was released. Will be null if no valid date was present in the
|
val dates: DateRange? = DateRange.from(songs.mapNotNull { it.date })
|
||||||
* metadata of any [Song]
|
|
||||||
*/
|
|
||||||
val date: Date? // TODO: Date ranges?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [Type] of this album, signifying the type of release it actually is. Defaults to
|
* 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
|
val dateAdded: Long
|
||||||
|
|
||||||
init {
|
init {
|
||||||
var earliestDate: Date? = null
|
|
||||||
var totalDuration: Long = 0
|
var totalDuration: Long = 0
|
||||||
var earliestDateAdded: Long = Long.MAX_VALUE
|
var earliestDateAdded: Long = Long.MAX_VALUE
|
||||||
|
|
||||||
// Do linking and value generation in the same loop for efficiency.
|
// Do linking and value generation in the same loop for efficiency.
|
||||||
for (song in songs) {
|
for (song in songs) {
|
||||||
song._link(this)
|
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) {
|
if (song.dateAdded < earliestDateAdded) {
|
||||||
earliestDateAdded = song.dateAdded
|
earliestDateAdded = song.dateAdded
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDuration += song.durationMs
|
totalDuration += song.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
date = earliestDate
|
|
||||||
durationMs = totalDuration
|
durationMs = totalDuration
|
||||||
dateAdded = earliestDateAdded
|
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 ---
|
// --- MUSIC UID CREATION UTILITIES ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -232,7 +232,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.album.date },
|
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
|
||||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||||
compareBy(NullableComparator.INT) { it.disc },
|
compareBy(NullableComparator.INT) { it.disc },
|
||||||
compareBy(NullableComparator.INT) { it.track },
|
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> =
|
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||||
compareByDescending(NullableComparator.DATE) { it.date },
|
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
|
||||||
compareBy(BasicComparator.ALBUM))
|
compareBy(BasicComparator.ALBUM))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
||||||
* @see Song.date
|
* @see Song.date
|
||||||
* @see Album.date
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDate : Mode() {
|
object ByDate : Mode() {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
|
@ -259,7 +259,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
|
|
||||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
|
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
|
||||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||||
compareBy(NullableComparator.INT) { it.disc },
|
compareBy(NullableComparator.INT) { it.disc },
|
||||||
compareBy(NullableComparator.INT) { it.track },
|
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> =
|
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||||
MultiComparator(
|
MultiComparator(
|
||||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
|
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
|
||||||
compareBy(BasicComparator.ALBUM))
|
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.
|
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
|
||||||
* @see Song.dateAdded
|
* @see Song.dateAdded
|
||||||
* @see Album.date
|
* @see Album.dates
|
||||||
*/
|
*/
|
||||||
object ByDateAdded : Mode() {
|
object ByDateAdded : Mode() {
|
||||||
override val intCode: Int
|
override val intCode: Int
|
||||||
|
@ -545,6 +545,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||||
val LONG = NullableComparator<Long>()
|
val LONG = NullableComparator<Long>()
|
||||||
/** A re-usable instance configured for [Date]s. */
|
/** A re-usable instance configured for [Date]s. */
|
||||||
val DATE = NullableComparator<Date>()
|
val DATE = NullableComparator<Date>()
|
||||||
|
/** A re-usable instance configured for [DateRange]s. */
|
||||||
|
val DATE_RANGE = NullableComparator<DateRange>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<string name="fmt_two" translatable="false">%1$s • %2$s</string>
|
<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_three" translatable="false">%1$s • %2$s • %3$s</string>
|
||||||
<string name="fmt_number" translatable="false">%d</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>
|
<string name="fmt_path">%1$s/%2$s</string>
|
||||||
|
|
||||||
<!-- Codec Namespace | Format names -->
|
<!-- Codec Namespace | Format names -->
|
||||||
|
|
Loading…
Reference in a new issue