From d4f74784bae8e0bb1005fa486ee3d9e788d2c548 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Fri, 15 Jul 2022 10:23:01 -0600 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + app/build.gradle | 2 +- .../detail/recycler/AlbumDetailAdapter.kt | 5 +- .../detail/recycler/ArtistDetailAdapter.kt | 10 +- .../auxio/home/list/AlbumListFragment.kt | 3 +- .../auxio/home/list/SongListFragment.kt | 2 +- .../java/org/oxycblt/auxio/music/Music.kt | 138 +++++++++++++++++- .../java/org/oxycblt/auxio/music/MusicUtil.kt | 43 +++--- .../auxio/music/system/ExoPlayerBackend.kt | 26 ++-- .../org/oxycblt/auxio/music/system/Indexer.kt | 6 +- .../auxio/music/system/MediaStoreBackend.kt | 13 +- .../playback/system/MediaSessionComponent.kt | 12 +- .../main/java/org/oxycblt/auxio/ui/Sort.kt | 55 +++---- .../org/oxycblt/auxio/util/PrimitiveUtil.kt | 4 + 14 files changed, 223 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4536b681..9f5da9922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 7dfe10130..8d41a597a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 --- diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index 6cdea9c38..336003fe4 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 662822b17..4e12bef2b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -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() { override fun areItemsTheSame(oldItem: Album, newItem: Album) = - oldItem.rawName == newItem.rawName && oldItem.year == newItem.year + oldItem.rawName == newItem.rawName && oldItem.date == newItem.date } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 53031221d..96801d068 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -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() { 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) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index aa314b5bb..e91233f4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -71,7 +71,7 @@ class SongListFragment : HomeListFragment() { 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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 79a058575..0e2047b99 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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) 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) : Comparable { + 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() + populateTokens(groups, tokens) + + if (tokens.isEmpty()) { + return null + } + + return Date(tokens) + } + + private fun populateTokens(groups: List, tokens: MutableList) { + 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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt index f87b6f04f..79ee1c49d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt @@ -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() // 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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt index f1873a6ea..e3b147165 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index f18eeb299..93a68db0d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt index 4efac93aa..0ed809f1f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt @@ -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 = diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index a6753305c..04a2d68f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt index bbe117059..a5658bba1 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 { 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> private constructor() : Comparator { + 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() + val DATE = NullableComparator() + } + } + + companion object { fun fromItemId(@IdRes itemId: Int) = when (itemId) { ByName.itemId -> ByName diff --git a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt index 9bc7b079b..2c1459b00 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt @@ -43,6 +43,10 @@ fun 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 --:--