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 --:--