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:
parent
7833ec4460
commit
d4f74784ba
14 changed files with 223 additions and 97 deletions
|
@ -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
|
||||
|
|
|
@ -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 ---
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 --:--
|
||||
|
|
Loading…
Reference in a new issue