From 82a64b5e17516edeef7ae83470bddcfda5fdb77d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 21 Jan 2023 16:43:41 -0700 Subject: [PATCH 01/66] music: accept zero positions with non-zero totals Accept positions that are zeroed, but only if there are non-zero total values as well. Sometimes zeroed positions are deliberate, but other times they are placeholders meant to indicate a lack of a position. To resolve this, Auxio now considers zeroed track/disc numbers in the presence of non-zero track/disc numbers to be valid. --- CHANGELOG.md | 6 +++ .../music/extractor/MediaStoreExtractor.kt | 12 +++--- .../music/extractor/MetadataExtractor.kt | 23 ++++++---- .../auxio/music/parsing/ParsingUtil.kt | 43 ++++++++++++++++--- .../auxio/music/parsing/ParsingUtilTest.kt | 34 ++++++++++++--- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5088867b..8d1b6ce7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## dev + +#### What's Improved +- Auxio will now accept zeroed track/disc numbers in the presence of non-zero total +track/disc fields. + ## 3.0.2 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 0145222aa..211d20a4b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -29,7 +29,8 @@ import androidx.core.database.getStringOrNull import java.io.File import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.parsing.parseId3v2Position +import org.oxycblt.auxio.music.parsing.parseId3v2PositionField +import org.oxycblt.auxio.music.parsing.transformPositionField import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.directoryCompat @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.storage.useQuery import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.nonZeroOrNull /** * The layer that loads music from the [MediaStore] database. This is an intermediate step in the @@ -564,8 +564,8 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) // the tag itself, which is to say that it is formatted as NN/TT tracks, where // N is the number and T is the total. Parse the number while ignoring the // total, as we have no use for it. - cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it } - cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it } + cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { raw.track = it } + cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { raw.disc = it } } } @@ -576,7 +576,7 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) * @return The track number extracted from the combined integer value, or null if the value was * zero. */ -private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull() +private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null) /** * Unpack the disc number from a combined track + disc [Int] field. These fields appear within @@ -584,4 +584,4 @@ private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull() * disc number is the 4th+ digit. * @return The disc number extracted from the combined integer field, or null if the value was zero. */ -private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull() +private fun Int.unpackDiscNo() = transformPositionField(div(1000), null) diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index b80b45882..cf7257194 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -23,7 +23,8 @@ import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever import kotlinx.coroutines.flow.flow import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.parsing.parseId3v2Position +import org.oxycblt.auxio.music.parsing.parseId3v2PositionField +import org.oxycblt.auxio.music.parsing.parseVorbisPositionField import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.logD @@ -182,10 +183,10 @@ class Task(context: Context, private val raw: Song.Raw) { textFrames["TSOT"]?.let { raw.sortName = it[0] } // Track. Only parse out the track number and ignore the total tracks value. - textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it } + textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { raw.track = it } // Disc. Only parse out the disc number and ignore the total discs value. - textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it } + textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it } // Dates are somewhat complicated, as not only did their semantics change from a flat year // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of @@ -278,13 +279,17 @@ class Task(context: Context, private val raw: Song.Raw) { comments["title"]?.let { raw.name = it[0] } comments["titlesort"]?.let { raw.sortName = it[0] } - // Track. The total tracks value is in a different comment, so we can just - // convert the entirety of this comment into a number. - comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it } + // Track. + parseVorbisPositionField( + comments["tracknumber"]?.first(), + (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) + ?.let { raw.track = it } - // Disc. The total discs value is in a different comment, so we can just - // convert the entirety of this comment into a number. - comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it } + // Disc. + parseVorbisPositionField( + comments["discnumber"]?.first(), + (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) + ?.let { raw.disc = it } // Vorbis dates are less complicated, but there are still several types // Our hierarchy for dates is as such: diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt index 658b1cbea..db9c53945 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt @@ -107,12 +107,45 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List /// --- ID3v2 PARSING --- /** - * Parse the number out of a ID3v2-style number + total position [String] field. These fields - * consist of a number and an (optional) total value delimited by a /. - * @return The number value extracted from the string field, or null if the value could not be - * parsed or if the value was zero. + * Parse an ID3v2-style position + total [String] field. These fields consist of a number and an + * (optional) total value delimited by a /. + * @return The position value extracted from the string field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + * @see transformPositionField */ -fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull() +fun String.parseId3v2PositionField() = + split('/', limit = 2).let { + transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull()) + } + +/** + * Parse a vorbis-style position + total field. These fields consist of two fields for the position + * and total numbers. + * @param pos The position value, or null if not present. + * @param total The total value, if not present. + * @return The position value extracted from the field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + * @see transformPositionField + */ +fun parseVorbisPositionField(pos: String?, total: String?) = + transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull()) + +/** + * Transform a raw position + total field into a position a way that tolerates placeholder values. + * @param pos The position value, or null if not present. + * @param total The total value, if not present. + * @return The position value extracted from the field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + */ +fun transformPositionField(pos: Int?, total: Int?) = + if (pos != null && (pos > 0 || (total?.nonZeroOrNull() != null))) { + pos + } else { + null + } /** * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer diff --git a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt index 687653a14..1a3025447 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt @@ -92,18 +92,40 @@ class ParsingUtilTest { } @Test - fun parseId3v2Position_correct() { - assertEquals(16, "16/32".parseId3v2Position()) + fun parseId3v2PositionField_correct() { + assertEquals(16, "16/32".parseId3v2PositionField()) + assertEquals(16, "16".parseId3v2PositionField()) } @Test - fun parseId3v2Position_noTotal() { - assertEquals(16, "16".parseId3v2Position()) + fun parseId3v2PositionField_zeroed() { + assertEquals(null, "0".parseId3v2PositionField()) + assertEquals(0, "0/32".parseId3v2PositionField()) } @Test - fun parseId3v2Position_wack() { - assertEquals(16, "16/".parseId3v2Position()) + fun parseId3v2PositionField_wack() { + assertEquals(16, "16/".parseId3v2PositionField()) + assertEquals(null, "a".parseId3v2PositionField()) + assertEquals(null, "a/b".parseId3v2PositionField()) + } + + @Test + fun parseVorbisPositionField_correct() { + assertEquals(16, parseVorbisPositionField("16", "32")) + assertEquals(16, parseVorbisPositionField("16", null)) + } + + @Test + fun parseVorbisPositionField_zeroed() { + assertEquals(null, parseVorbisPositionField("0", null)) + assertEquals(0, parseVorbisPositionField("0", "32")) + } + + @Test + fun parseVorbisPositionField_wack() { + assertEquals(null, parseVorbisPositionField("a", null)) + assertEquals(null, parseVorbisPositionField("a", "b")) } @Test From 26f0fb7abaa030b80adb4e0966f3156d31e8e127 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 21 Jan 2023 17:22:15 -0700 Subject: [PATCH 02/66] detail: add support for disc subtittles Add support for disc subtitle information This allows disc groups to become named, which is useful for certain multi-part albums. Resolves #331. --- CHANGELOG.md | 4 ++ .../java/org/oxycblt/auxio/detail/Detail.kt | 7 --- .../oxycblt/auxio/detail/DetailViewModel.kt | 5 ++- .../detail/recycler/AlbumDetailAdapter.kt | 39 +++++++++------- .../java/org/oxycblt/auxio/music/Music.kt | 9 ++-- .../auxio/music/extractor/CacheExtractor.kt | 8 +++- .../music/extractor/MetadataExtractor.kt | 44 ++++++++++--------- .../org/oxycblt/auxio/music/library/Sort.kt | 13 +++--- .../java/org/oxycblt/auxio/music/tags/Disc.kt | 31 +++++++++++++ .../playback/system/MediaSessionComponent.kt | 2 +- app/src/main/res/layout/item_disc_header.xml | 37 ++++++++++------ .../org/oxycblt/auxio/music/tags/DiscTest.kt | 39 ++++++++++++++++ 12 files changed, 168 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1b6ce7b..2d3137720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ## dev +#### What's New +- Added support for disc subtitles + #### What's Improved - Auxio will now accept zeroed track/disc numbers in the presence of non-zero total track/disc fields. + ## 3.0.2 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt index 1788b4ddd..310c10cfd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -29,13 +29,6 @@ import org.oxycblt.auxio.music.storage.MimeType */ data class SortHeader(@StringRes val titleRes: Int) : Item -/** - * A header variation that delimits between disc groups. - * @param disc The disc number to be displayed on the header. - * @author Alexander Capehart (OxygenCobalt) - */ -data class DiscHeader(val disc: Int) : Item - /** * The properties of a [Song]'s file. * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index d51cd3734..c3b3580d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.music.tags.Disc import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* @@ -323,11 +324,11 @@ class DetailViewModel(application: Application) : // songs up by disc and then delimit the groups by a disc header. val songs = albumSongSort.songs(album.songs) // Songs without disc tags become part of Disc 1. - val byDisc = songs.groupBy { it.disc ?: 1 } + val byDisc = songs.groupBy { it.disc ?: Disc(1, null) } if (byDisc.size > 1) { logD("Album has more than one disc, interspersing headers") for (entry in byDisc.entries) { - data.add(DiscHeader(entry.key)) + data.add(entry.key) data.addAll(entry.value) } } else { 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 6a8611cb7..2fb514f2d 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 @@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable @@ -26,13 +27,13 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding -import org.oxycblt.auxio.detail.DiscHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.tags.Disc import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -60,7 +61,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene when (getItem(position)) { // Support the Album header, sub-headers for each disc, and special album songs. is Album -> AlbumDetailViewHolder.VIEW_TYPE - is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE + is Disc -> DiscViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -68,7 +69,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent) - DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent) + DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) else -> super.onCreateViewHolder(parent, viewType) } @@ -77,7 +78,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene super.onBindViewHolder(holder, position) when (val item = getItem(position)) { is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) - is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) + is Disc -> (holder as DiscViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item, listener) } } @@ -88,7 +89,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene } // The album and disc headers should be full-width in all configurations. val item = getItem(position) - return item is Album || item is DiscHeader + return item is Album || item is Disc } private companion object { @@ -99,8 +100,8 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene return when { oldItem is Album && newItem is Album -> AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) - oldItem is DiscHeader && newItem is DiscHeader -> - DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is Disc && newItem is Disc -> + DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) @@ -182,18 +183,22 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite } /** - * A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use - * [from] to create an instance. + * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from] + * to create an instance. * @author Alexander Capehart (OxygenCobalt) */ -private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : +private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) : RecyclerView.ViewHolder(binding.root) { /** * Bind new data to this instance. - * @param discHeader The new [DiscHeader] to bind. + * @param disc The new [disc] to bind. */ - fun bind(discHeader: DiscHeader) { - binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.disc) + fun bind(disc: Disc) { + binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) + binding.discName.apply { + text = disc.name + isGone = disc.name == null + } } companion object { @@ -206,13 +211,13 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : * @return A new instance. */ fun from(parent: View) = - DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) + DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleDiffCallback() { - override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = - oldItem.disc == newItem.disc + object : SimpleDiffCallback() { + override fun areContentsTheSame(oldItem: Disc, newItem: Disc) = + oldItem.number == newItem.number && oldItem.name == newItem.name } } } 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 39aeab02d..ba7d0aee4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.tags.Date +import org.oxycblt.auxio.music.tags.Disc import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -340,8 +341,8 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { /** The track number. Will be null if no valid track number was present in the metadata. */ val track = raw.track - /** The disc number. Will be null if no valid disc number was present in the metadata. */ - val disc = raw.disc + /** The [Disc] number. Will be null if no valid disc number was present in the metadata. */ + val disc = raw.disc?.let { Disc(it, raw.subtitle) } /** The release [Date]. Will be null if no valid date was present in the metadata. */ val date = raw.date @@ -573,8 +574,10 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { var sortName: String? = null, /** @see Song.track */ var track: Int? = null, - /** @see Song.disc */ + /** @see Disc.number */ var disc: Int? = null, + /** @See Disc.name */ + var subtitle: String? = null, /** @see Song.date */ var date: Date? = null, /** @see Album.Raw.mediaStoreId */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt index 94531c376..9c34e2af3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -186,6 +186,7 @@ private class CacheDatabase(context: Context) : append("${Columns.SORT_NAME} STRING,") append("${Columns.TRACK} INT,") append("${Columns.DISC} INT,") + append("${Columns.SUBTITLE} STRING,") append("${Columns.DATE} STRING,") append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,") append("${Columns.ALBUM_NAME} STRING NOT NULL,") @@ -243,6 +244,7 @@ private class CacheDatabase(context: Context) : val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK) val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC) + val subtitleIndex = cursor.getColumnIndex(Columns.SUBTITLE) val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE) val albumMusicBrainzIdIndex = @@ -281,6 +283,7 @@ private class CacheDatabase(context: Context) : raw.track = cursor.getIntOrNull(trackIndex) raw.disc = cursor.getIntOrNull(discIndex) + raw.subtitle = cursor.getStringOrNull(subtitleIndex) raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from) raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex) @@ -346,6 +349,7 @@ private class CacheDatabase(context: Context) : put(Columns.TRACK, rawSong.track) put(Columns.DISC, rawSong.disc) + put(Columns.SUBTITLE, rawSong.subtitle) put(Columns.DATE, rawSong.date?.toString()) put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId) @@ -414,6 +418,8 @@ private class CacheDatabase(context: Context) : const val TRACK = "track" /** @see Song.Raw.disc */ const val DISC = "disc" + /** @see Song.Raw.subtitle */ + const val SUBTITLE = "subtitle" /** @see Song.Raw.date */ const val DATE = "date" /** @see Song.Raw.albumMusicBrainzId */ @@ -442,7 +448,7 @@ private class CacheDatabase(context: Context) : companion object { private const val DB_NAME = "auxio_music_cache.db" - private const val DB_VERSION = 2 + private const val DB_VERSION = 3 private const val TABLE_RAW_SONGS = "raw_songs" @Volatile private var INSTANCE: CacheDatabase? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index cf7257194..91880466b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -178,15 +178,16 @@ class Task(context: Context, private val raw: Song.Raw) { */ private fun populateWithId3v2(textFrames: Map>) { // Song - textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] } - textFrames["TIT2"]?.let { raw.name = it[0] } - textFrames["TSOT"]?.let { raw.sortName = it[0] } + textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it.first() } + textFrames["TIT2"]?.let { raw.name = it.first() } + textFrames["TSOT"]?.let { raw.sortName = it.first() } - // Track. Only parse out the track number and ignore the total tracks value. + // Track. textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { raw.track = it } - // Disc. Only parse out the disc number and ignore the total discs value. + // Disc and it's subtitle name. textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { raw.disc = it } + textFrames["TSST"]?.let { raw.subtitle = it.first() } // Dates are somewhat complicated, as not only did their semantics change from a flat year // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of @@ -204,9 +205,9 @@ class Task(context: Context, private val raw: Song.Raw) { ?.let { raw.date = it } // Album - textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] } - textFrames["TALB"]?.let { raw.albumName = it[0] } - textFrames["TSOA"]?.let { raw.albumSortName = it[0] } + textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it.first() } + textFrames["TALB"]?.let { raw.albumName = it.first() } + textFrames["TSOA"]?.let { raw.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { raw.releaseTypes = it } @@ -244,19 +245,19 @@ class Task(context: Context, private val raw: Song.Raw) { ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null val tdat = textFrames["TDAT"] - return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) { + return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) { // TDAT frames consist of a 4-digit string where the first two digits are // the month and the last two digits are the day. - val mm = tdat[0].substring(0..1).toInt() - val dd = tdat[0].substring(2..3).toInt() + val mm = tdat.first().substring(0..1).toInt() + val dd = tdat.first().substring(2..3).toInt() val time = textFrames["TIME"] - if (time != null && time[0].length == 4 && time[0].isDigitsOnly()) { + if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) { // TIME frames consist of a 4-digit string where the first two digits are // the hour and the last two digits are the minutes. No second value is // possible. - val hh = time[0].substring(0..1).toInt() - val mi = time[0].substring(2..3).toInt() + val hh = time.first().substring(0..1).toInt() + val mi = time.first().substring(2..3).toInt() // Able to return a full date. Date.from(year, mm, dd, hh, mi) } else { @@ -275,9 +276,9 @@ class Task(context: Context, private val raw: Song.Raw) { */ private fun populateWithVorbis(comments: Map>) { // Song - comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] } - comments["title"]?.let { raw.name = it[0] } - comments["titlesort"]?.let { raw.sortName = it[0] } + comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it.first() } + comments["title"]?.let { raw.name = it.first() } + comments["titlesort"]?.let { raw.sortName = it.first() } // Track. parseVorbisPositionField( @@ -285,11 +286,12 @@ class Task(context: Context, private val raw: Song.Raw) { (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) ?.let { raw.track = it } - // Disc. + // Disc and it's subtitle name. parseVorbisPositionField( comments["discnumber"]?.first(), (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) ?.let { raw.disc = it } + comments["discsubtitle"]?.let { raw.subtitle = it.first() } // Vorbis dates are less complicated, but there are still several types // Our hierarchy for dates is as such: @@ -303,9 +305,9 @@ class Task(context: Context, private val raw: Song.Raw) { ?.let { raw.date = it } // Album - comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } - comments["album"]?.let { raw.albumName = it[0] } - comments["albumsort"]?.let { raw.albumSortName = it[0] } + comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it.first() } + comments["album"]?.let { raw.albumName = it.first() } + comments["albumsort"]?.let { raw.albumSortName = it.first() } comments["releasetype"]?.let { raw.releaseTypes = it } // Artist diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt index 2cda0e76f..f419d3747 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt @@ -24,6 +24,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.library.Sort.Mode import org.oxycblt.auxio.music.tags.Date +import org.oxycblt.auxio.music.tags.Disc /** * A sorting method. @@ -215,7 +216,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getSongComparator(isAscending: Boolean): Comparator = MultiComparator( compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album }, - compareBy(NullableComparator.INT) { it.disc }, + compareBy(NullableComparator.DISC) { it.disc }, compareBy(NullableComparator.INT) { it.track }, compareBy(BasicComparator.SONG)) } @@ -236,7 +237,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates }, compareByDescending(BasicComparator.ALBUM) { it.album }, - compareBy(NullableComparator.INT) { it.disc }, + compareBy(NullableComparator.DISC) { it.disc }, compareBy(NullableComparator.INT) { it.track }, compareBy(BasicComparator.SONG)) @@ -263,7 +264,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { MultiComparator( compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates }, compareByDescending(BasicComparator.ALBUM) { it.album }, - compareBy(NullableComparator.INT) { it.disc }, + compareBy(NullableComparator.DISC) { it.disc }, compareBy(NullableComparator.INT) { it.track }, compareBy(BasicComparator.SONG)) @@ -342,7 +343,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getSongComparator(isAscending: Boolean): Comparator = MultiComparator( - compareByDynamic(isAscending, NullableComparator.INT) { it.disc }, + compareByDynamic(isAscending, NullableComparator.DISC) { it.disc }, compareBy(NullableComparator.INT) { it.track }, compareBy(BasicComparator.SONG)) } @@ -360,7 +361,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getSongComparator(isAscending: Boolean): Comparator = MultiComparator( - compareBy(NullableComparator.INT) { it.disc }, + compareBy(NullableComparator.DISC) { it.disc }, compareByDynamic(isAscending, NullableComparator.INT) { it.track }, compareBy(BasicComparator.SONG)) } @@ -545,6 +546,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { val INT = NullableComparator() /** A re-usable instance configured for [Long]s. */ val LONG = NullableComparator() + /** A re-usable instance configured for [Disc]s */ + val DISC = NullableComparator() /** A re-usable instance configured for [Date.Range]s. */ val DATE_RANGE = NullableComparator() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt new file mode 100644 index 000000000..f6f19f97c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/Disc.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.tags + +import org.oxycblt.auxio.list.Item + +/** + * A disc identifier for a song. + * @param number The disc number. + * @param name The name of the disc group, if any. Null if not present. + */ +class Disc(val number: Int, val name: String?) : Item, Comparable { + override fun hashCode() = number.hashCode() + override fun equals(other: Any?) = other is Disc && number == other.number + override fun compareTo(other: Disc) = number.compareTo(other.number) +} 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 bd6900c51..b6e34615b 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 @@ -300,7 +300,7 @@ class MediaSessionComponent(private val context: Context, private val listener: builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) } song.disc?.let { - builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong()) } song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) } diff --git a/app/src/main/res/layout/item_disc_header.xml b/app/src/main/res/layout/item_disc_header.xml index 4aa39b3ef..2a291dbae 100644 --- a/app/src/main/res/layout/item_disc_header.xml +++ b/app/src/main/res/layout/item_disc_header.xml @@ -1,5 +1,5 @@ - + + + tools:visibility="gone" + app:layout_constraintStart_toEndOf="@+id/disc_icon" + app:layout_constraintTop_toBottomOf="@+id/disc_number" + tools:text="Part 1" /> - + diff --git a/app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt b/app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt new file mode 100644 index 000000000..eff1131d7 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/tags/DiscTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.tags + +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiscTest { + @Test + fun disc_equals_correct() { + val a = Disc(1, "Part I") + val b = Disc(1, "Part I") + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun disc_equals_inconsistentNames() { + val a = Disc(1, "Part I") + val b = Disc(1, null) + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } +} From 6c604a9aa565ef61d4a148c087676cd653b28e15 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 23 Jan 2023 08:42:18 -0700 Subject: [PATCH 03/66] search: split off search algorithm from viewmodel Split off the search algorithm from the ViewModel into a separate object. Part of an initiative to eliminate all non-parameter usage of contexts in ViewModels. --- .../org/oxycblt/auxio/search/SearchEngine.kt | 123 ++++++++++++++++++ .../oxycblt/auxio/search/SearchViewModel.kt | 113 +++++----------- 2 files changed, 155 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt new file mode 100644 index 000000000..831f758f9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.search + +import android.content.Context +import java.text.Normalizer +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Song + +/** + * Implements the fuzzy-ish searching algorithm used in the search view. + * @author Alexander Capehart + */ +interface SearchEngine { + /** + * Begin a search. + * @param items The items to search over. + * @param query The query to search for. + * @return A list of items filtered by the given query. + */ + suspend fun search(items: Items, query: String): Items + + /** + * Input/output data to use with [SearchEngine]. + * @param songs A list of [Song]s, null if empty. + * @param albums A list of [Album]s, null if empty. + * @param artists A list of [Artist]s, null if empty. + * @param genres A list of [Genre]s, null if empty. + */ + data class Items( + val songs: List?, + val albums: List?, + val artists: List?, + val genres: List? + ) + + private class Real(private val context: Context) : SearchEngine { + override suspend fun search(items: Items, query: String) = + Items( + songs = + items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q) }, + albums = items.albums?.searchListImpl(query), + artists = items.artists?.searchListImpl(query), + genres = items.genres?.searchListImpl(query)) + + /** + * Search a given [Music] list. + * @param query The query to search for. The routine will compare this query to the names of + * each object in the list and + * @param fallback Additional comparison code to run if the item does not match the query + * initially. This can be used to compare against additional attributes to improve search + * result quality. + */ + private inline fun List.searchListImpl( + query: String, + fallback: (String, T) -> Boolean = { _, _ -> false } + ) = + filter { + // See if the plain resolved name matches the query. This works for most + // situations. + val name = it.resolveName(context) + if (name.contains(query, ignoreCase = true)) { + return@filter true + } + + // See if the sort name matches. This can sometimes be helpful as certain + // libraries + // will tag sort names to have a alphabetized version of the title. + val sortName = it.rawSortName + if (sortName != null && sortName.contains(query, ignoreCase = true)) { + return@filter true + } + + // As a last-ditch effort, see if the normalized name matches. This will replace + // any non-alphabetical characters with their alphabetical representations, + // which + // could make it match the query. + val normalizedName = + NORMALIZATION_SANITIZE_REGEX.replace( + Normalizer.normalize(name, Normalizer.Form.NFKD), "") + if (normalizedName.contains(query, ignoreCase = true)) { + return@filter true + } + + fallback(query, it) + } + .ifEmpty { null } + + private companion object { + /** + * Converts the output of [Normalizer] to remove any junk characters added by it's + * replacements. + */ + val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+") + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): SearchEngine = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 9341a7390..7a6b6f968 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -21,7 +21,6 @@ import android.app.Application import androidx.annotation.IdRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import java.text.Normalizer import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,7 +34,6 @@ import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD /** @@ -47,6 +45,7 @@ class SearchViewModel(application: Application) : private val musicStore = MusicStore.getInstance() private val searchSettings = SearchSettings.from(application) private val playbackSettings = PlaybackSettings.from(application) + private var searchEngine = SearchEngine.from(application) private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -101,87 +100,43 @@ class SearchViewModel(application: Application) : } } - private fun searchImpl(library: Library, query: String): List { - val sort = Sort(Sort.Mode.ByName, true) + private suspend fun searchImpl(library: Library, query: String): List { val filterMode = searchSettings.searchFilterMode - val results = mutableListOf() - // Note: A null filter mode maps to the "All" filter option, hence the check. + val items = + if (filterMode == null) { + // A nulled filter mode means to not filter anything. + SearchEngine.Items(library.songs, library.albums, library.artists, library.genres) + } else { + SearchEngine.Items( + songs = if (filterMode == MusicMode.SONGS) library.songs else null, + albums = if (filterMode == MusicMode.ALBUMS) library.albums else null, + artists = if (filterMode == MusicMode.ARTISTS) library.artists else null, + genres = if (filterMode == MusicMode.GENRES) library.genres else null) + } - if (filterMode == null || filterMode == MusicMode.ARTISTS) { - library.artists.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_artists)) - results.addAll(sort.artists(it)) + val results = searchEngine.search(items, query) + + return buildList { + results.artists?.let { artists -> + add(Header(R.string.lbl_artists)) + addAll(SORT.artists(artists)) + } + results.albums?.let { albums -> + add(Header(R.string.lbl_albums)) + addAll(SORT.albums(albums)) + } + results.genres?.let { genres -> + add(Header(R.string.lbl_genres)) + addAll(SORT.genres(genres)) + } + results.songs?.let { songs -> + add(Header(R.string.lbl_songs)) + addAll(SORT.songs(songs)) } } - - if (filterMode == null || filterMode == MusicMode.ALBUMS) { - library.albums.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_albums)) - results.addAll(sort.albums(it)) - } - } - - if (filterMode == null || filterMode == MusicMode.GENRES) { - library.genres.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_genres)) - results.addAll(sort.genres(it)) - } - } - - if (filterMode == null || filterMode == MusicMode.SONGS) { - library.songs - .searchListImpl(query) { q, song -> song.path.name.contains(q) } - ?.let { - results.add(Header(R.string.lbl_songs)) - results.addAll(sort.songs(it)) - } - } - - // Handle if we were canceled while searching. - return results } - /** - * Search a given [Music] list. - * @param query The query to search for. The routine will compare this query to the names of - * each object in the list and - * @param fallback Additional comparison code to run if the item does not match the query - * initially. This can be used to compare against additional attributes to improve search result - * quality. - */ - private inline fun List.searchListImpl( - query: String, - fallback: (String, T) -> Boolean = { _, _ -> false } - ) = - filter { - // See if the plain resolved name matches the query. This works for most situations. - val name = it.resolveName(context) - if (name.contains(query, ignoreCase = true)) { - return@filter true - } - - // See if the sort name matches. This can sometimes be helpful as certain libraries - // will tag sort names to have a alphabetized version of the title. - val sortName = it.rawSortName - if (sortName != null && sortName.contains(query, ignoreCase = true)) { - return@filter true - } - - // As a last-ditch effort, see if the normalized name matches. This will replace - // any non-alphabetical characters with their alphabetical representations, which - // could make it match the query. - val normalizedName = - NORMALIZATION_SANITIZE_REGEX.replace( - Normalizer.normalize(name, Normalizer.Form.NFKD), "") - if (normalizedName.contains(query, ignoreCase = true)) { - return@filter true - } - - fallback(query, it) - } - .ifEmpty { null } - /** * Returns the ID of the filter option to currently highlight. * @return A menu item ID of the filtering option selected. @@ -218,10 +173,6 @@ class SearchViewModel(application: Application) : } private companion object { - /** - * Converts the output of [Normalizer] to remove any junk characters added by it's - * replacements. - */ - val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+") + val SORT = Sort(Sort.Mode.ByName, true) } } From a6f2d82107d28ac30f4ce01d609d36a9815bde17 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 23 Jan 2023 09:06:20 -0700 Subject: [PATCH 04/66] detail: split off song property extraction Split off the song property extraction code into a music package class. This is part of an initiative to remove non-parameter uses of contexts in ViewModels. --- .../java/org/oxycblt/auxio/IntegerTable.kt | 4 +- .../java/org/oxycblt/auxio/detail/Detail.kt | 43 ------ .../oxycblt/auxio/detail/DetailViewModel.kt | 96 +++----------- .../oxycblt/auxio/detail/SongDetailDialog.kt | 20 +-- .../auxio/detail/recycler/DetailAdapter.kt | 26 ++-- .../main/java/org/oxycblt/auxio/list/Data.kt | 14 +- .../auxio/list/recycler/ViewHolders.kt | 26 ++-- .../auxio/music/extractor/AudioInfo.kt | 123 ++++++++++++++++++ .../org/oxycblt/auxio/search/SearchAdapter.kt | 12 +- .../oxycblt/auxio/search/SearchViewModel.kt | 10 +- app/src/main/res/layout/item_header.xml | 11 +- app/src/main/res/layout/item_sort_header.xml | 17 +-- 12 files changed, 213 insertions(+), 189 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/detail/Detail.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index e2cbcb5ce..3c724786a 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -31,8 +31,8 @@ object IntegerTable { const val VIEW_TYPE_ARTIST = 0xA002 /** GenreViewHolder */ const val VIEW_TYPE_GENRE = 0xA003 - /** HeaderViewHolder */ - const val VIEW_TYPE_HEADER = 0xA004 + /** BasicHeaderViewHolder */ + const val VIEW_TYPE_BASIC_HEADER = 0xA004 /** SortHeaderViewHolder */ const val VIEW_TYPE_SORT_HEADER = 0xA005 /** AlbumDetailViewHolder */ diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt deleted file mode 100644 index 310c10cfd..000000000 --- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.detail - -import androidx.annotation.StringRes -import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.storage.MimeType - -/** - * A header variation that displays a button to open a sort menu. - * @param titleRes The string resource to use as the header title - * @author Alexander Capehart (OxygenCobalt) - */ -data class SortHeader(@StringRes val titleRes: Int) : Item - -/** - * The properties of a [Song]'s file. - * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. - * @param sampleRateHz The sample rate, in hertz. - * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined. - * @author Alexander Capehart (OxygenCobalt) - */ -data class SongProperties( - val bitrateKbps: Int?, - val sampleRateHz: Int?, - val resolvedMimeType: MimeType -) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index c3b3580d3..4dada7534 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -18,8 +18,6 @@ package org.oxycblt.auxio.detail import android.app.Application -import android.media.MediaExtractor -import android.media.MediaFormat import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -30,13 +28,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Header +import org.oxycblt.auxio.detail.recycler.SortHeader +import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.extractor.AudioInfo import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.library.Sort -import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.tags.Disc import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.playback.PlaybackSettings @@ -54,6 +53,7 @@ class DetailViewModel(application: Application) : private val musicStore = MusicStore.getInstance() private val musicSettings = MusicSettings.from(application) private val playbackSettings = PlaybackSettings.from(application) + private val audioInfoProvider = AudioInfo.Provider.from(application) private var currentSongJob: Job? = null @@ -64,9 +64,9 @@ class DetailViewModel(application: Application) : val currentSong: StateFlow get() = _currentSong - private val _songProperties = MutableStateFlow(null) - /** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */ - val songProperties: StateFlow = _songProperties + private val _songAudioInfo = MutableStateFlow(null) + /** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */ + val songAudioInfo: StateFlow = _songAudioInfo // --- ALBUM --- @@ -156,7 +156,7 @@ class DetailViewModel(application: Application) : val song = currentSong.value if (song != null) { - _currentSong.value = library.sanitize(song)?.also(::loadProperties) + _currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo) logD("Updated song to ${currentSong.value}") } @@ -181,7 +181,7 @@ class DetailViewModel(application: Application) : /** * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and - * [songProperties] will be updated to align with the new [Song]. + * [songAudioInfo] will be updated to align with the new [Song]. * @param uid The UID of the [Song] to load. Must be valid. */ fun setSongUid(uid: Music.UID) { @@ -190,7 +190,7 @@ class DetailViewModel(application: Application) : return } logD("Opening Song [uid: $uid]") - _currentSong.value = requireMusic(uid)?.also(::loadProperties) + _currentSong.value = requireMusic(uid)?.also(::refreshAudioInfo) } /** @@ -238,83 +238,21 @@ class DetailViewModel(application: Application) : private fun requireMusic(uid: Music.UID) = musicStore.library?.find(uid) /** - * Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to - * [songProperties]. + * Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo]. * @param song The song to load. */ - private fun loadProperties(song: Song) { + private fun refreshAudioInfo(song: Song) { // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() - _songProperties.value = null + _songAudioInfo.value = null currentSongJob = viewModelScope.launch(Dispatchers.IO) { - val properties = this@DetailViewModel.loadPropertiesImpl(song) + val info = audioInfoProvider.extract(song) yield() - _songProperties.value = properties + _songAudioInfo.value = info } } - private fun loadPropertiesImpl(song: Song): SongProperties { - // While we would use ExoPlayer to extract this information, it doesn't support - // common data like bit rate in progressive data sources due to there being no - // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. - val extractor = MediaExtractor() - - try { - extractor.setDataSource(context, song.uri, emptyMap()) - } catch (e: Exception) { - // Can feasibly fail with invalid file formats. Note that this isn't considered - // an error condition in the UI, as there is still plenty of other song information - // that we can show. - logW("Unable to extract song attributes.") - logW(e.stackTraceToString()) - return SongProperties(null, null, song.mimeType) - } - - // Get the first track from the extractor (This is basically always the only - // track we need to analyze). - val format = extractor.getTrackFormat(0) - - // Accessing fields can throw an exception if the fields are not present, and - // the new method for using default values is not available on lower API levels. - // So, we are forced to handle the exception and map it to a saner null value. - val bitrate = - try { - // Convert bytes-per-second to kilobytes-per-second. - format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 - } catch (e: NullPointerException) { - logD("Unable to extract bit rate field") - null - } - - val sampleRate = - try { - format.getInteger(MediaFormat.KEY_SAMPLE_RATE) - } catch (e: NullPointerException) { - logE("Unable to extract sample rate field") - null - } - - val resolvedMimeType = - if (song.mimeType.fromFormat != null) { - // ExoPlayer was already able to populate the format. - song.mimeType - } else { - // ExoPlayer couldn't populate the format somehow, populate it here. - val formatMimeType = - try { - format.getString(MediaFormat.KEY_MIME) - } catch (e: NullPointerException) { - logE("Unable to extract mime type field") - null - } - - MimeType(song.mimeType.fromExtension, formatMimeType) - } - - return SongProperties(bitrate, sampleRate, resolvedMimeType) - } - private fun refreshAlbumList(album: Album) { logD("Refreshing album data") val data = mutableListOf(album) @@ -368,7 +306,7 @@ class DetailViewModel(application: Application) : logD("Release groups for this artist: ${byReleaseGroup.keys}") for (entry in byReleaseGroup.entries.sortedBy { it.key }) { - data.add(Header(entry.key.headerTitleRes)) + data.add(BasicHeader(entry.key.headerTitleRes)) data.addAll(entry.value) } @@ -386,7 +324,7 @@ class DetailViewModel(application: Application) : logD("Refreshing genre data") val data = mutableListOf(genre) // Genre is guaranteed to always have artists and songs. - data.add(Header(R.string.lbl_artists)) + data.add(BasicHeader(R.string.lbl_artists)) data.addAll(genre.artists) data.add(SortHeader(R.string.lbl_songs)) data.addAll(genreSongSort.songs(genre.songs)) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index cf2d516d7..ea663824c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -27,6 +27,7 @@ import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSongDetailBinding import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.extractor.AudioInfo import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.androidActivityViewModels @@ -54,10 +55,10 @@ class SongDetailDialog : ViewBindingDialogFragment() { super.onBindingCreated(binding, savedInstanceState) // DetailViewModel handles most initialization from the navigation argument. detailModel.setSongUid(args.itemUid) - collectImmediately(detailModel.currentSong, detailModel.songProperties, ::updateSong) + collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong) } - private fun updateSong(song: Song?, properties: SongProperties?) { + private fun updateSong(song: Song?, info: AudioInfo?) { if (song == null) { // Song we were showing no longer exists. findNavController().navigateUp() @@ -65,28 +66,27 @@ class SongDetailDialog : ViewBindingDialogFragment() { } val binding = requireBinding() - if (properties != null) { - // Finished loading Song properties, populate and show the list of Song information. + if (info != null) { + // Finished loading song audio info, populate and show the list of Song information. binding.detailLoading.isInvisible = true binding.detailContainer.isInvisible = false val context = requireContext() binding.detailFileName.setText(song.path.name) binding.detailRelativeDir.setText(song.path.parent.resolveName(context)) - binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context)) + binding.detailFormat.setText(info.resolvedMimeType.resolveName(context)) binding.detailSize.setText(Formatter.formatFileSize(context, song.size)) binding.detailDuration.setText(song.durationMs.formatDurationMs(true)) - if (properties.bitrateKbps != null) { - binding.detailBitrate.setText( - getString(R.string.fmt_bitrate, properties.bitrateKbps)) + if (info.bitrateKbps != null) { + binding.detailBitrate.setText(getString(R.string.fmt_bitrate, info.bitrateKbps)) } else { binding.detailBitrate.setText(R.string.def_bitrate) } - if (properties.sampleRateHz != null) { + if (info.sampleRateHz != null) { binding.detailSampleRate.setText( - getString(R.string.fmt_sample_rate, properties.sampleRateHz)) + getString(R.string.fmt_sample_rate, info.sampleRateHz)) } else { binding.detailSampleRate.setText(R.string.def_sample_rate) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 2ce12a786..a529aa5ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -19,12 +19,13 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.databinding.ItemSortHeaderBinding -import org.oxycblt.auxio.detail.SortHeader +import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener @@ -52,21 +53,21 @@ abstract class DetailAdapter( override fun getItemViewType(position: Int) = when (getItem(position)) { // Implement support for headers and sort headers - is Header -> HeaderViewHolder.VIEW_TYPE + is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent) + BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (val item = getItem(position)) { - is Header -> (holder as HeaderViewHolder).bind(item) + is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item) is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener) } } @@ -74,7 +75,7 @@ abstract class DetailAdapter( override fun isItemFullWidth(position: Int): Boolean { // Headers should be full-width in all configurations. val item = getItem(position) - return item is Header || item is SortHeader + return item is BasicHeader || item is SortHeader } /** An extended [SelectableListListener] for [DetailAdapter] implementations. */ @@ -105,8 +106,8 @@ abstract class DetailAdapter( object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { - oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is BasicHeader && newItem is BasicHeader -> + BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is SortHeader && newItem is SortHeader -> SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false @@ -117,8 +118,15 @@ abstract class DetailAdapter( } /** - * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a - * button opening a menu for sorting. Use [from] to create an instance. + * A header variation that displays a button to open a sort menu. + * @param titleRes The string resource to use as the header title + * @author Alexander Capehart (OxygenCobalt) + */ +data class SortHeader(@StringRes override val titleRes: Int) : Header + +/** + * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds + * a button opening a menu for sorting. Use [from] to create an instance. * @author Alexander Capehart (OxygenCobalt) */ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt index 878a6a9d3..e77d0afb4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -24,6 +24,16 @@ interface Item /** * A "header" used for delimiting groups of data. - * @param titleRes The string resource used for the header's title. + * @author Alexander Capehart (OxygenCobalt) */ -data class Header(@StringRes val titleRes: Int) : Item +interface Header : Item { + /** The string resource used for the header's title. */ + val titleRes: Int +} + +/** + * A basic header with no additional actions. + * @param titleRes The string resource used for the header's title. + * @author Alexander Capehart (OxygenCobalt) + */ +data class BasicHeader(@StringRes override val titleRes: Int) : Header diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index d72bf6814..aaafffc4b 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -24,7 +24,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding -import org.oxycblt.auxio.list.Header +import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SimpleDiffCallback @@ -241,23 +241,23 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding } /** - * A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance. + * A [RecyclerView.ViewHolder] that displays a [BasicHeader]. Use [from] to create an instance. * @author Alexander Capehart (OxygenCobalt) */ -class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : +class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : RecyclerView.ViewHolder(binding.root) { /** * Bind new data to this instance. - * @param header The new [Header] to bind. + * @param basicHeader The new [BasicHeader] to bind. */ - fun bind(header: Header) { - logD(binding.context.getString(header.titleRes)) - binding.title.text = binding.context.getString(header.titleRes) + fun bind(basicHeader: BasicHeader) { + logD(binding.context.getString(basicHeader.titleRes)) + binding.title.text = binding.context.getString(basicHeader.titleRes) } companion object { /** Unique ID for this ViewHolder type. */ - const val VIEW_TYPE = IntegerTable.VIEW_TYPE_HEADER + const val VIEW_TYPE = IntegerTable.VIEW_TYPE_BASIC_HEADER /** * Create a new instance. @@ -265,13 +265,15 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin * @return A new instance. */ fun from(parent: View) = - HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater)) + BasicHeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleDiffCallback
() { - override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = - oldItem.titleRes == newItem.titleRes + object : SimpleDiffCallback() { + override fun areContentsTheSame( + oldItem: BasicHeader, + newItem: BasicHeader + ): Boolean = oldItem.titleRes == newItem.titleRes } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt new file mode 100644 index 000000000..22f38d040 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/AudioInfo.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.extractor + +import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW + +/** + * The properties of a [Song]'s file. + * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. + * @param sampleRateHz The sample rate, in hertz. + * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined. + * @author Alexander Capehart (OxygenCobalt) + */ +data class AudioInfo( + val bitrateKbps: Int?, + val sampleRateHz: Int?, + val resolvedMimeType: MimeType +) { + /** Implements the process of extracting [AudioInfo] from a given [Song]. */ + interface Provider { + /** + * Extract the [AudioInfo] of a given [Song]. + * @param song The [Song] to read. + * @return The [AudioInfo] of the [Song], if possible to obtain. + */ + suspend fun extract(song: Song): AudioInfo + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): Provider = RealAudioInfoProvider(context) + } + } +} + +private class RealAudioInfoProvider(private val context: Context) : AudioInfo.Provider { + // While we would use ExoPlayer to extract this information, it doesn't support + // common data like bit rate in progressive data sources due to there being no + // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. + private val extractor = MediaExtractor() + + override suspend fun extract(song: Song): AudioInfo { + try { + extractor.setDataSource(context, song.uri, emptyMap()) + } catch (e: Exception) { + // Can feasibly fail with invalid file formats. Note that this isn't considered + // an error condition in the UI, as there is still plenty of other song information + // that we can show. + logW("Unable to extract song attributes.") + logW(e.stackTraceToString()) + return AudioInfo(null, null, song.mimeType) + } + + // Get the first track from the extractor (This is basically always the only + // track we need to analyze). + val format = extractor.getTrackFormat(0) + + // Accessing fields can throw an exception if the fields are not present, and + // the new method for using default values is not available on lower API levels. + // So, we are forced to handle the exception and map it to a saner null value. + val bitrate = + try { + // Convert bytes-per-second to kilobytes-per-second. + format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 + } catch (e: NullPointerException) { + logD("Unable to extract bit rate field") + null + } + + val sampleRate = + try { + format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + } catch (e: NullPointerException) { + logE("Unable to extract sample rate field") + null + } + + val resolvedMimeType = + if (song.mimeType.fromFormat != null) { + // ExoPlayer was already able to populate the format. + song.mimeType + } else { + // ExoPlayer couldn't populate the format somehow, populate it here. + val formatMimeType = + try { + format.getString(MediaFormat.KEY_MIME) + } catch (e: NullPointerException) { + logE("Unable to extract mime type field") + null + } + + MimeType(song.mimeType.fromExtension, formatMimeType) + } + + extractor.release() + + return AudioInfo(bitrate, sampleRate, resolvedMimeType) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 62c157bd8..ae1350f60 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -44,7 +44,7 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> AlbumViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE - is Header -> HeaderViewHolder.VIEW_TYPE + is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -54,7 +54,7 @@ class SearchAdapter(private val listener: SelectableListListener) : AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) - HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent) + BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } @@ -65,11 +65,11 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> (holder as AlbumViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) is Genre -> (holder as GenreViewHolder).bind(item, listener) - is Header -> (holder as HeaderViewHolder).bind(item) + is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item) } } - override fun isItemFullWidth(position: Int) = getItem(position) is Header + override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader /** * Make sure that the top header has a correctly configured divider visibility. This would @@ -94,8 +94,8 @@ class SearchAdapter(private val listener: SelectableListListener) : ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) - oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is BasicHeader && newItem is BasicHeader -> + BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 7a6b6f968..f69851cdd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Header +import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicStore @@ -119,19 +119,19 @@ class SearchViewModel(application: Application) : return buildList { results.artists?.let { artists -> - add(Header(R.string.lbl_artists)) + add(BasicHeader(R.string.lbl_artists)) addAll(SORT.artists(artists)) } results.albums?.let { albums -> - add(Header(R.string.lbl_albums)) + add(BasicHeader(R.string.lbl_albums)) addAll(SORT.albums(albums)) } results.genres?.let { genres -> - add(Header(R.string.lbl_genres)) + add(BasicHeader(R.string.lbl_genres)) addAll(SORT.genres(genres)) } results.songs?.let { songs -> - add(Header(R.string.lbl_songs)) + add(BasicHeader(R.string.lbl_songs)) addAll(SORT.songs(songs)) } } diff --git a/app/src/main/res/layout/item_header.xml b/app/src/main/res/layout/item_header.xml index 0f7e21fa8..c623a41ee 100644 --- a/app/src/main/res/layout/item_header.xml +++ b/app/src/main/res/layout/item_header.xml @@ -1,15 +1,8 @@ - - - - - \ No newline at end of file + tools:text="Songs" /> \ No newline at end of file diff --git a/app/src/main/res/layout/item_sort_header.xml b/app/src/main/res/layout/item_sort_header.xml index f98c5488e..58738f8cf 100644 --- a/app/src/main/res/layout/item_sort_header.xml +++ b/app/src/main/res/layout/item_sort_header.xml @@ -1,38 +1,31 @@ - - -