From 62ee46cfe6a516e1224a44386c76c24ddc119869 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Sep 2022 10:09:38 -0600 Subject: [PATCH] music: add multi-artist support Add semi-complete support for multiple artists. This changeset completely reworks the music linker to add the following new behaviors: 1. Artists are now derived from both artist and album artist tags, with them being linked to songs and albums respectively 2. Albums and songs can now have multiple artists that can be distinct from eachother 3. Previous Genre picking infrastructure has been removed and replaced with artist picking infrastructure. "Play from genre" has been retired entirely. This is a clean break to the previous artist model and may not work with all libraries. Steps to migrate the music library will be added to the changelog. Resolves #195. --- CHANGELOG.md | 13 +- .../auxio/detail/AlbumDetailFragment.kt | 32 +- .../auxio/detail/ArtistDetailFragment.kt | 19 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 10 +- .../auxio/detail/GenreDetailFragment.kt | 18 +- .../detail/recycler/AlbumDetailAdapter.kt | 4 +- .../detail/recycler/ArtistDetailAdapter.kt | 49 +- .../detail/recycler/GenreDetailAdapter.kt | 11 +- .../auxio/home/list/AlbumListFragment.kt | 6 +- .../auxio/home/list/ArtistListFragment.kt | 5 +- .../auxio/home/list/SongListFragment.kt | 24 +- .../auxio/image/extractor/Components.kt | 16 +- .../java/org/oxycblt/auxio/music/Music.kt | 531 +++++++----------- .../org/oxycblt/auxio/music/MusicStore.kt | 2 +- .../main/java/org/oxycblt/auxio/music/Sort.kt | 52 +- .../main/java/org/oxycblt/auxio/music/Tags.kt | 293 ++++++++++ .../music/extractor/MetadataExtractor.kt | 2 +- .../auxio/music/extractor/ParsingUtil.kt | 13 +- ...hoiceAdapter.kt => ArtistChoiceAdapter.kt} | 36 +- ...ePickerDialog.kt => ArtistPickerDialog.kt} | 44 +- .../oxycblt/auxio/music/picker/PickerMode.kt | 2 +- .../auxio/music/picker/PickerViewModel.kt | 22 +- .../auxio/music/settings/SeparatorsDialog.kt | 4 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 35 +- .../auxio/playback/PlaybackBarFragment.kt | 2 +- .../auxio/playback/PlaybackPanelFragment.kt | 30 +- .../auxio/playback/PlaybackViewModel.kt | 6 +- .../auxio/playback/queue/QueueAdapter.kt | 2 +- .../playback/system/MediaSessionComponent.kt | 18 +- .../playback/system/NotificationComponent.kt | 5 +- .../oxycblt/auxio/search/SearchFragment.kt | 18 +- .../org/oxycblt/auxio/settings/Settings.kt | 6 +- .../oxycblt/auxio/ui/NavigationViewModel.kt | 2 +- .../oxycblt/auxio/ui/fragment/MenuFragment.kt | 21 +- .../oxycblt/auxio/ui/recycler/ViewHolders.kt | 14 +- .../org/oxycblt/auxio/util/PrimitiveUtil.kt | 3 + .../java/org/oxycblt/auxio/widgets/Forms.kt | 2 +- app/src/main/res/layout/dialog_separators.xml | 9 +- app/src/main/res/navigation/nav_main.xml | 13 +- app/src/main/res/values/settings.xml | 27 +- app/src/main/res/values/strings.xml | 10 +- app/src/main/res/xml/prefs_main.xml | 4 +- build.gradle | 2 +- 43 files changed, 845 insertions(+), 592 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/Tags.kt rename app/src/main/java/org/oxycblt/auxio/music/picker/{GenreChoiceAdapter.kt => ArtistChoiceAdapter.kt} (55%) rename app/src/main/java/org/oxycblt/auxio/music/picker/{GenrePickerDialog.kt => ArtistPickerDialog.kt} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf8a7a6e..78196c0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog -## dev +## 3.0.0 #### What's New -- Added support for songs with multiple genres -- Reworked music hashing to be even more reliable (Will wipe playback state) +- Massively reworked music loading system: + - Auxio now supports multiple artists + - Auxio now supports multiple genres + - Artists and album artists are now both given equal importance in the UI + - Made music hashing rely on the more reliable MD5 + - **This may impact your library.** Instructions on how to update your library to result in a good + artist experience will be added to the FAQ. #### What's Improved - Sorting now takes accented characters into account @@ -17,9 +22,11 @@ - Fixed issue where the playback progress would continue in the notification even if audio focus was lost - Fixed issue where the app would crash if a song menu in the genre UI was opened +- Fixed issue where the artist name would not be shown in the OS audio switcher menu #### What's Changed - Ignore MediaStore tags is now on by default +- Removed the "Play from genre" option in the library/detail playback mode settings #### Dev/Meta - Completed migration to reactive playback system diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 775f620b2..7b574222c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -120,7 +120,7 @@ class AlbumDetailFragment : true } R.id.action_go_artist -> { - navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist) + onNavigateToArtist() true } else -> false @@ -132,16 +132,18 @@ class AlbumDetailFragment : when (settings.detailPlaybackMode) { null, MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.SONGS -> playbackModel.playFromAll(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> if (item.genres.size > 1) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY) + MusicMode.ARTISTS -> { + if (item.artists.size == 1) { + playbackModel.playFromArtist(item, item.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY) + ) ) - ) - } else { - playbackModel.playFromGenre(item, item.genres[0]) + } } + else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") } } @@ -177,12 +179,16 @@ class AlbumDetailFragment : } override fun onNavigateToArtist() { - findNavController() - .navigate( - AlbumDetailFragmentDirections.actionShowArtist( - unlikelyToBeNull(detailModel.currentAlbum.value).artist.uid + val album = unlikelyToBeNull(detailModel.currentAlbum.value) + if (album.artists.size == 1) { + navModel.exploreNavigateTo(album.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW) ) ) + } } private fun handleItemChange(album: Album?) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 0e913baca..7a3ccf8ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -122,18 +122,21 @@ class ArtistDetailFragment : when (item) { is Song -> { when (settings.detailPlaybackMode) { - null, MusicMode.ARTISTS -> playbackModel.playFromArtist(item) + null -> playbackModel.playFromArtist(item, unlikelyToBeNull(detailModel.currentArtist.value)) MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.GENRES -> if (item.genres.size > 1) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY) + MusicMode.ARTISTS -> { + if (item.artists.size == 1) { + playbackModel.playFromArtist(item, item.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY) + ) ) - ) - } else { - playbackModel.playFromGenre(item, item.genres[0]) + } } + else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") } } is Album -> navModel.exploreNavigateTo(item) 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 60e00d535..aa46b4608 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -240,7 +240,7 @@ class DetailViewModel(application: Application) : private fun refreshArtistData(artist: Artist) { logD("Refreshing artist data") val data = mutableListOf(artist) - val albums = Sort(Sort.Mode.ByYear, false).albums(artist.albums) + val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums) val byReleaseGroup = albums.groupBy { @@ -265,8 +265,12 @@ class DetailViewModel(application: Application) : data.addAll(entry.value) } - data.add(SortHeader(R.string.lbl_songs)) - data.addAll(artistSort.songs(artist.songs)) + // Artists may not be linked to any songs, only include a header entry if we have any. + if (artist.songs.isNotEmpty()) { + data.add(SortHeader(R.string.lbl_songs)) + data.addAll(artistSort.songs(artist.songs)) + } + _artistData.value = data.toList() } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index bdf32be36..08fb959e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -125,16 +125,18 @@ class GenreDetailFragment : null -> playbackModel.playFromGenre(item, unlikelyToBeNull(detailModel.currentGenre.value)) MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> if (item.genres.size > 1) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY) + MusicMode.ARTISTS -> { + if (item.artists.size == 1) { + playbackModel.playFromArtist(item, item.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY) + ) ) - ) - } else { - playbackModel.playFromGenre(item, item.genres[0]) + } } + else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") } } 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 c3fd0f677..af2dba420 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 @@ -114,7 +114,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite binding.detailName.text = item.resolveName(binding.context) binding.detailSubhead.apply { - text = item.artist.resolveName(context) + text = item.resolveArtistContents(context) setOnClickListener { listener.onNavigateToArtist() } } @@ -144,7 +144,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && - oldItem.artist.rawName == newItem.artist.rawName && + oldItem.areArtistContentsTheSame(newItem) && oldItem.date == newItem.date && oldItem.songs.size == newItem.songs.size && oldItem.durationMs == newItem.durationMs && 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 e9553baf8..3301028eb 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 @@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -27,10 +28,8 @@ import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre 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.IndicatorAdapter import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.MenuItemListener @@ -110,26 +109,30 @@ private class ArtistDetailViewHolder private constructor(private val binding: It binding.detailType.text = binding.context.getString(R.string.lbl_artist) binding.detailName.text = item.resolveName(binding.context) - // Get the genre that corresponds to the most songs in this artist, which would be - // the most "Prominent" genre. - val genresByAmount = mutableMapOf() - for (song in item.songs) { - for (genre in song.genres) { - genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1 + if (item.songs.isNotEmpty()) { + binding.detailSubhead.apply { + isVisible = true + text = item.resolveGenreContents(binding.context) } + + binding.detailInfo.text = + binding.context.getString( + R.string.fmt_two, + binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), + binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size) + ) + + binding.detailPlayButton.isEnabled = true + binding.detailShuffleButton.isEnabled = true + } else { + // The artist is a + binding.detailSubhead.isVisible = false + binding.detailInfo.text = + binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size) + binding.detailPlayButton.isEnabled = false + binding.detailShuffleButton.isEnabled = false } - binding.detailSubhead.text = - genresByAmount.maxByOrNull { it.value }?.key?.resolveName(binding.context) - ?: binding.context.getString(R.string.def_genre) - - binding.detailInfo.text = - binding.context.getString( - R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), - binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size) - ) - binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } } @@ -140,7 +143,13 @@ private class ArtistDetailViewHolder private constructor(private val binding: It fun new(parent: View) = ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - val DIFFER = ArtistViewHolder.DIFFER + val DIFFER = object : SimpleItemCallback() { + override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = + oldItem.rawName == newItem.rawName && + oldItem.areGenreContentsTheSame(newItem) && + oldItem.albums.size == newItem.albums.size && + oldItem.songs.size == newItem.songs.size + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index c09f569fb..cc9e6354b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -95,9 +96,13 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite binding.detailCover.bind(item) binding.detailType.text = binding.context.getString(R.string.lbl_genre) binding.detailName.text = item.resolveName(binding.context) - binding.detailSubhead.text = - binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size) - binding.detailInfo.text = item.durationMs.formatDurationMs(false) + binding.detailSubhead.isVisible = false + binding.detailInfo.text = binding.context.getString( + R.string.fmt_two, + binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size), + item.durationMs.formatDurationMs(false) + ) + binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } } 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 3ff5a0058..2d66e7642 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 @@ -66,11 +66,11 @@ class AlbumListFragment : HomeListFragment() { // By Name -> Use Name is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() } - // By Artist -> Use Artist Name - is Sort.Mode.ByArtist -> album.artist.collationKey?.run { sourceString.first().uppercase() } + // By Artist -> Use name of first artist + is Sort.Mode.ByArtist -> album.artists[0].collationKey?.run { sourceString.first().uppercase() } // Year -> Use Full Year - is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext()) + is Sort.Mode.ByDate -> album.date?.resolveYear(requireContext()) // Duration -> Use formatted duration is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index ec77ca008..7bd2e948c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.MenuItemListener import org.oxycblt.auxio.ui.recycler.SyncListDiffer import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.nonZeroOrNull /** * A [HomeListFragment] for showing a list of [Artist]s. @@ -62,10 +63,10 @@ class ArtistListFragment : HomeListFragment() { is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() } // Duration -> Use formatted duration - is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false) + is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) // Count -> Use song count - is Sort.Mode.ByCount -> artist.songs.size.toString() + is Sort.Mode.ByCount -> artist.songs.size.nonZeroOrNull()?.toString() // Unsupported sort, error gracefully else -> null 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 b58fa0d2f..8ed2041be 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 @@ -79,14 +79,14 @@ class SongListFragment : HomeListFragment() { // Name -> Use name is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() } - // Artist -> Use Artist Name - is Sort.Mode.ByArtist -> song.album.artist.collationKey?.run { sourceString.first().uppercase() } + // Artist -> Use name of first artist + is Sort.Mode.ByArtist -> song.album.artists[0].collationKey?.run { sourceString.first().uppercase() } // Album -> Use Album Name is Sort.Mode.ByAlbum -> song.album.collationKey?.run { sourceString.first().uppercase() } // Year -> Use Full Year - is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext()) + is Sort.Mode.ByDate -> song.album.date?.resolveYear(requireContext()) // Duration -> Use formatted duration is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) @@ -115,16 +115,18 @@ class SongListFragment : HomeListFragment() { when (settings.libPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> if (item.genres.size > 1) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY) + MusicMode.ARTISTS -> { + if (item.artists.size == 1) { + playbackModel.playFromArtist(item, item.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY) + ) ) - ) - } else { - playbackModel.playFromGenre(item, item.genres[0]) + } } + else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index c7ba9fddb..91f0c7c37 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -108,22 +108,8 @@ private constructor( private val genre: Genre ) : BaseFetcher() { override suspend fun fetch(): FetchResult? { - // Genre logic is the most complicated, as we want to ensure album cover variation (i.e - // all four covers shouldn't be from the same artist) while also still leveraging mosaics - // whenever possible. So, if there are more than four distinct artists in a genre, make - // it so that one artist only adds one album cover to the mosaic. Otherwise, use order - // albums normally. - val artists = genre.songs.groupBy { it.album.artist }.keys - val albums = - Sort(Sort.Mode.ByName, true).albums(genre.songs.groupBy { it.album }.keys).run { - if (artists.size > 4) { - distinctBy { it.artist.rawName } - } else { - this - } - } + val results = genre.albums.mapAtMost(4) { fetchArt(context, it) } - val results = albums.mapAtMost(4) { album -> fetchArt(context, album) } return createMosaic(context, results, size) } 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 c06778399..80d301f94 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -23,15 +23,12 @@ import android.content.Context import android.os.Parcelable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Date.Companion.from import org.oxycblt.auxio.music.extractor.parseId3GenreNames import org.oxycblt.auxio.music.extractor.parseMultiValue import org.oxycblt.auxio.music.extractor.parseReleaseType import org.oxycblt.auxio.settings.Settings 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 import java.security.MessageDigest @@ -39,7 +36,6 @@ import java.text.CollationKey import java.text.Collator import java.util.UUID import kotlin.math.max -import kotlin.math.min // --- MUSIC MODELS --- @@ -200,10 +196,6 @@ sealed class Music : Item { sealed class MusicParent : Music() { /** The songs that this parent owns. */ abstract val songs: List - - override fun _finalize() { - check(songs.isNotEmpty()) { "Invalid parent: No songs" } - } } /** @@ -214,7 +206,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { override val uid = UID.hashed(MusicMode.SONGS) { // Song UIDs are based on the raw data without parsing so that they remain // consistent across music setting changes. Parents are not held up to the - // same standard since grouping is directly linked to settings. + // same standard since grouping is already inherently linked to settings. update(raw.name) update(raw.albumName) update(raw.date) @@ -274,41 +266,63 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { private var _album: Album? = null - /** The album of this song. */ + /** + * The album of this song. Every song is guaranteed to have one and only one album, + * with a "directory" album being used if no album tag can be found. + */ val album: Album get() = unlikelyToBeNull(_album) - // TODO: Multi-artist support - // private val _artists: MutableList = mutableListOf() + private val artistNames = raw.artistNames.parseMultiValue(settings) - private val artistName = raw.artistNames.parseMultiValue(settings) - .joinToString().ifEmpty { null } + private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings) - private val albumArtistName = raw.albumArtistNames.parseMultiValue(settings) - .joinToString().ifEmpty { null } + private val artistSortNames = raw.artistSortNames.parseMultiValue(settings) - private val artistSortName = raw.artistSortNames.parseMultiValue(settings) - .joinToString().ifEmpty { null } + private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings) - private val albumArtistSortName = raw.albumArtistSortNames.parseMultiValue(settings) - .joinToString().ifEmpty { null } - - /** - * Resolve the artist name for this song in particular. First uses the artist tag, and then - * falls back to the album artist tag (i.e parent artist name) - */ - fun resolveIndividualArtistName(context: Context) = - artistName ?: album.artist.resolveName(context) - - fun areArtistContentsTheSame(other: Song): Boolean { - if (other.artistName != null && artistName != null) { - return other.artistName == artistName - } - - return album.artist.rawName == other.album.artist.rawName + private val rawArtists = artistNames.mapIndexed { i, name -> + Artist.Raw(name, artistSortNames.getOrNull(i)) } - private val _genres: MutableList = mutableListOf() + private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name -> + Artist.Raw(name, albumArtistSortNames.getOrNull(i)) + } + + private val _artists = mutableListOf() + + /** + * The artists of this song. Most often one, but there could be multiple. These artists + * are derived from the artists tag and not the album artists tag, so they may differ from + * the artists of the album. + */ + val artists: List + get() = _artists + + /** + * Resolve the artists of this song into a human-readable name. First tries to use artist + * tags, then falls back to album artist tags. + */ + fun resolveArtistContents(context: Context) = + artists.joinToString { it.resolveName(context) } + + /** + * Utility method for recyclerview diffing that checks if resolveArtistContents is the + * same without a context. + */ + fun areArtistContentsTheSame(other: Song): Boolean { + for (i in 0 until max(artists.size, other.artists.size)) { + val a = artists.getOrNull(i) ?: return false + val b = other.artists.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false + } + } + + return true + } + + private val _genres = mutableListOf() /** * The genres of this song. Most often one, but there could be multiple. There will always be at @@ -317,36 +331,47 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { val genres: List get() = _genres + /** + * Resolve the genres of the song into a human-readable string. + */ + fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) } + // --- INTERNAL FIELDS --- + val _rawGenres = raw.genreNames.parseId3GenreNames(settings) + .map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) } + + val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty { + listOf(Artist.Raw(null, null)) + } + val _rawAlbum = Album.Raw( mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" }, name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, sortName = raw.albumSortName, releaseType = raw.albumReleaseType.parseReleaseType(settings), - rawArtist = - if (albumArtistName != null) { - Artist.Raw(albumArtistName, albumArtistSortName) - } else { - Artist.Raw(artistName, artistSortName) - } + rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) } ) - val _rawGenres = raw.genreNames.parseId3GenreNames(settings) - .map { Genre.Raw(it) }.ifEmpty { listOf(Genre.Raw(null)) } - fun _link(album: Album) { _album = album } + fun _link(artist: Artist) { + _artists.add(artist) + } + fun _link(genre: Genre) { _genres.add(genre) } override fun _finalize() { - (checkNotNull(_album) { "Malformed song: Album is null" }) + checkNotNull(_album) { "Malformed song: No album" } + check(_artists.isNotEmpty()) { "Malformed song: No artists" } + Sort(Sort.Mode.ByName, true).artistsInPlace(_artists) check(_genres.isNotEmpty()) { "Malformed song: No genres" } + Sort(Sort.Mode.ByName, true).genresInPlace(_genres) } class Raw @@ -387,7 +412,7 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( // I don't know if there is any situation where an artist will have two albums with // the exact same name, but if there is, I would love to know. update(raw.name) - update(raw.rawArtist.name) + update(raw.rawArtists.map { it.name }) } override val rawName = raw.name @@ -416,23 +441,33 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( /** The earliest date a song in this album was added. */ val dateAdded: Long - private var _artist: Artist? = null + /** + * The artists of this album. Usually one, but there may be more. These are derived from + * the album artist first, so they may differ from the song artists. + */ + private val _artists = mutableListOf() + val artists: List get() = _artists - /** The parent artist of this album. */ - val artist: Artist - get() = unlikelyToBeNull(_artist) + /** + * Resolve the artists of this album in a human-readable manner. + */ + fun resolveArtistContents(context: Context) = + artists.joinToString { it.resolveName(context) } - // --- INTERNAL FIELDS --- + /** + * Utility for RecyclerView differs to check if resolveArtistContents is the same without + * a context. + */ + fun areArtistContentsTheSame(other: Album): Boolean { + for (i in 0 until max(artists.size, other.artists.size)) { + val a = artists.getOrNull(i) ?: return false + val b = other.artists.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false + } + } - val _rawArtist = raw.rawArtist - - fun _link(artist: Artist) { - _artist = artist - } - - override fun _finalize() { - super._finalize() - checkNotNull(_artist) { "Invalid album: Artist is null " } + return true } init { @@ -462,32 +497,43 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( dateAdded = earliestDateAdded } + // --- INTERNAL FIELDS --- + + val _rawArtists = raw.rawArtists + + fun _link(artist: Artist) { + _artists.add(artist) + } + + override fun _finalize() { + check(songs.isNotEmpty()) { "Malformed album: Empty" } + check(_artists.isNotEmpty()) { "Malformed album: No artists" } + Sort(Sort.Mode.ByName, true).artistsInPlace(_artists) + } + class Raw( val mediaStoreId: Long, val name: String, val sortName: String?, val releaseType: ReleaseType?, - val rawArtist: Artist.Raw + val rawArtists: List ) { - private val hashCode = 31 * name.lowercase().hashCode() + rawArtist.hashCode() + private val hashCode = 31 * name.lowercase().hashCode() + rawArtists.hashCode() override fun hashCode() = hashCode override fun equals(other: Any?) = - other is Raw && name.equals(other.name, true) && rawArtist == other.rawArtist + other is Raw && name.equals(other.name, true) && rawArtists == other.rawArtists } } /** - * An artist. This is derived from the album artist first, and then the normal artist second. + * An abstract artist. This is derived from both album artist values and artist values in + * albums and songs respectively. * @author OxygenCobalt */ class Artist -constructor( - raw: Raw, - /** The albums of this artist. */ - val albums: List -) : MusicParent() { +constructor(raw: Raw, songAlbums: List) : MusicParent() { override val uid = UID.hashed(MusicMode.ARTISTS) { update(raw.name) } override val rawName = raw.name @@ -498,22 +544,71 @@ constructor( override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) - private val _songs = mutableListOf() - override val songs = _songs + /** + * The songs of this artist. This might be empty. + */ + override val songs: List - /** The total duration of songs in this artist, in millis. */ - val durationMs: Long + /** The total duration of songs in this artist, in millis. Null if there are no songs. */ + val durationMs: Long? - init { - var totalDuration = 0L + /** The albums of this artist. This will never be empty. */ + val albums: List - for (album in albums) { - album._link(this) - _songs.addAll(album.songs) - totalDuration += album.durationMs + private lateinit var genres: List + + /** + * Resolve the combined genres of this artist into a human-readable string. + */ + fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) } + + /** + * Utility for RecyclerView differs to check if resolveGenreContents is the same without + * a context. + */ + fun areGenreContentsTheSame(other: Artist): Boolean { + for (i in 0 until max(genres.size, other.genres.size)) { + val a = genres.getOrNull(i) ?: return false + val b = other.genres.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false + } } - durationMs = totalDuration + return true + } + + init { + val distinctSongs = mutableSetOf() + val distinctAlbums = mutableSetOf() + + for (music in songAlbums) { + when (music) { + is Song -> { + music._link(this) + distinctSongs.add(music) + distinctAlbums.add(music.album) + } + + is Album -> { + music._link(this) + distinctAlbums.add(music) + } + + else -> error("Unexpected input music ${music::class.simpleName}") + } + } + + songs = distinctSongs.toList() + albums = distinctAlbums.toList() + durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() + } + + override fun _finalize() { + check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" } + + genres = Sort(Sort.Mode.ByName, true).genres(songs.flatMapTo(mutableSetOf()) { it.genres }) + .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } } } class Raw(val name: String?, val sortName: String?) { @@ -550,15 +645,29 @@ class Genre constructor(raw: Raw, override val songs: List) : MusicParent( /** The total duration of the songs in this genre, in millis. */ val durationMs: Long + /** The albums of this genre. */ + val albums: List + init { var totalDuration = 0L + val distinctAlbums = mutableSetOf() for (song in songs) { song._link(this) + distinctAlbums.add(song.album) totalDuration += song.durationMs } durationMs = totalDuration + + albums = Sort(Sort.Mode.ByName, true).albums(distinctAlbums) + .sortedByDescending { album -> + album.songs.count { it.genres.contains(this) } + } + } + + override fun _finalize() { + check(songs.isNotEmpty()) { "Malformed genre: Empty" } } class Raw(val name: String?) { @@ -591,7 +700,7 @@ fun MessageDigest.update(date: Date?) { } /** Update the digest using a list of strings. */ -fun MessageDigest.update(strings: List) { +fun MessageDigest.update(strings: List) { strings.forEach(::update) } @@ -656,263 +765,3 @@ fun ByteArray.toUuid(): UUID { .or(get(15).toLong().and(0xFF)) ) } - -/** - * An ISO-8601/RFC 3339 Date. - * - * Unlike a typical Date within the standard library, this class just represents the ID3v2/Vorbis - * date format, which is largely assumed to be a subset of ISO-8601. No validation outside of format - * validation is done. - * - * 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 implementation is hidden. To instantiate one, use [from]. - * 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 code 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 = tokens[0] - - /** Resolve the year field in a way suitable for the UI. */ - fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year) - - private val month = tokens.getOrNull(1) - - private val day = tokens.getOrNull(2) - - private val hour = tokens.getOrNull(3) - - private val minute = tokens.getOrNull(4) - - private val second = tokens.getOrNull(5) - - 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 { - 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.append('Z')).toFixedString(2)}") - append(":${(second ?: return this.append('Z')).toFixedString(2)}") - return this.append('Z') - } - - 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 from(year: Int) = fromTokens(listOf(year)) - - fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) - - fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = - fromTokens(listOf(year, month, day, hour, minute)) - - fun from(timestamp: String): Date? { - val groups = - (ISO8601_REGEX.matchEntire(timestamp) ?: return null) - .groupValues - .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } - - return fromTokens(groups) - } - - private fun fromTokens(tokens: List): Date? { - val out = mutableListOf() - validateTokens(tokens, out) - if (out.isEmpty()) { - return null - } - - return Date(out) - } - - private fun validateTokens(src: List, dst: MutableList) { - dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) - dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) - dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) - dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return) - dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) - dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) - } - } -} - -/** - * Represents the type of release a particular album is. - * - * This can be used to differentiate between album sub-types like Singles, EPs, Compilations, and - * others. Internally, it operates on a reduced version of the MusicBrainz release type - * specification. It can be extended if there is demand. - * - * @author OxygenCobalt - */ -sealed class ReleaseType { - abstract val refinement: Refinement? - abstract val stringRes: Int - - data class Album(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_album - Refinement.LIVE -> R.string.lbl_album_live - Refinement.REMIX -> R.string.lbl_album_remix - } - } - - data class EP(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_ep - Refinement.LIVE -> R.string.lbl_ep_live - Refinement.REMIX -> R.string.lbl_ep_remix - } - } - - data class Single(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_single - Refinement.LIVE -> R.string.lbl_single_live - Refinement.REMIX -> R.string.lbl_single_remix - } - } - - data class Compilation(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = when (refinement) { - null -> R.string.lbl_compilation - Refinement.LIVE -> R.string.lbl_compilation_live - Refinement.REMIX -> R.string.lbl_compilation_remix - } - } - - object Soundtrack : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_soundtrack - } - - object Mix : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mix - } - - object Mixtape : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mixtape - } - - /** - * Roughly analogous to the MusicBrainz "live" and "remix" secondary types. Unlike the main - * types, these only modify an existing, primary type. They are not implemented for secondary - * types, however they may be expanded to compilations in the future. - */ - enum class Refinement { - LIVE, - REMIX - } - - companion object { - // Note: The parsing code is extremely clever in order to reduce duplication. It's - // better just to read the specification behind release types than follow this code. - - fun parse(types: List): ReleaseType? { - val primary = types.getOrNull(0) ?: return null - - // Primary types should be the first one in sequence. The spec makes no mention of - // whether primary types are a pre-requisite for secondary types, so we assume that - // it isn't. There are technically two other types, but those are unrelated to music - // and thus we don't support them. - return when { - primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } - primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } - primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } - else -> types.parseSecondaryTypes(0) { Album(it) } - } - } - - private inline fun List.parseSecondaryTypes( - secondaryIdx: Int, - convertRefinement: (Refinement?) -> ReleaseType - ): ReleaseType { - val secondary = getOrNull(secondaryIdx) - - return if (secondary.equals("compilation", true)) { - // Secondary type is a compilation, actually parse the third type - // and put that into a compilation if needed. - parseSecondaryTypeImpl(getOrNull(secondaryIdx + 1)) { Compilation(it) } - } else { - // Secondary type is a plain value, use the original values given. - parseSecondaryTypeImpl(secondary, convertRefinement) - } - } - - private inline fun parseSecondaryTypeImpl( - type: String?, - convertRefinement: (Refinement?) -> ReleaseType - ) = when { - // Parse all the types that have no children - type.equals("soundtrack", true) -> Soundtrack - type.equals("mixtape/street", true) -> Mixtape - type.equals("dj-mix", true) -> Mix - type.equals("live", true) -> convertRefinement(Refinement.LIVE) - type.equals("remix", true) -> convertRefinement(Refinement.REMIX) - else -> convertRefinement(null) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 1b0644598..02300b9bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -102,7 +102,7 @@ class MusicStore private constructor() { * not [T], null will be returned. */ @Suppress("UNCHECKED_CAST") - fun find(uid: Music.UID): T? = uidMap[uid] as? T + fun find(uid: Music.UID) = uidMap[uid] as? T /** Sanitize an old item to find the corresponding item in a new library. */ fun sanitize(song: Song) = find(song.uid) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt index 35ba7c4c2..281751b76 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt @@ -21,13 +21,14 @@ import androidx.annotation.IdRes import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Sort.Mode +import kotlin.math.max /** * Represents the sort modes used in Auxio. * * Sorting can be done by Name, Artist, Album, and others. Sorting of names is always * case-insensitive and article-aware. Certain datatypes may only support a subset of sorts since - * certain sorts cannot be easily applied to them (For Example, [Mode.ByArtist] and [Mode.ByYear] or + * certain sorts cannot be easily applied to them (For Example, [Mode.ByArtist] and [Mode.ByDate] or * [Mode.ByAlbum]). * * Internally, sorts are saved as an integer in the following format @@ -78,11 +79,11 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { albums.sortWith(mode.getAlbumComparator(isAscending)) } - private fun artistsInPlace(artists: MutableList) { + fun artistsInPlace(artists: MutableList) { artists.sortWith(mode.getArtistComparator(isAscending)) } - private fun genresInPlace(genres: MutableList) { + fun genresInPlace(genres: MutableList) { genres.sortWith(mode.getGenreComparator(isAscending)) } @@ -154,7 +155,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getSongComparator(ascending: Boolean): Comparator = MultiComparator( - compareByDynamic(ascending, BasicComparator.ARTIST) { it.album.artist }, + compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists }, compareByDescending(NullableComparator.DATE) { it.album.date }, compareByDescending(BasicComparator.ALBUM) { it.album }, compareBy(NullableComparator.INT) { it.disc }, @@ -164,14 +165,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getAlbumComparator(ascending: Boolean): Comparator = MultiComparator( - compareByDynamic(ascending, BasicComparator.ARTIST) { it.artist }, + compareByDynamic(ascending, ListComparator.ARTISTS) { it.artists }, compareByDescending(NullableComparator.DATE) { it.date }, compareBy(BasicComparator.ALBUM) ) } - /** Sort by the year of an item, only supported by [Album] and [Song] */ - object ByYear : Mode() { + /** Sort by the date of an item, only supported by [Album] and [Song] */ + object ByDate : Mode() { override val intCode: Int get() = IntegerTable.SORT_BY_YEAR @@ -216,7 +217,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getArtistComparator(ascending: Boolean): Comparator = MultiComparator( - compareByDynamic(ascending) { it.durationMs }, + compareByDynamic(ascending, NullableComparator.LONG) { it.durationMs }, compareBy(BasicComparator.ARTIST) ) @@ -243,7 +244,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { override fun getArtistComparator(ascending: Boolean): Comparator = MultiComparator( - compareByDynamic(ascending) { it.songs.size }, + compareByDynamic(ascending, NullableComparator.INT) { it.songs.size }, compareBy(BasicComparator.ARTIST) ) @@ -362,6 +363,32 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { } } + private class ListComparator(private val inner: Comparator) : Comparator> { + override fun compare(a: List, b: List): Int { + for (i in 0 until max(a.size, b.size)) { + val ai = a.getOrNull(i) + val bi = b.getOrNull(i) + when { + ai != null && bi != null -> { + val result = inner.compare(ai, bi) + if (result != 0) { + return result + } + } + ai == null && bi != null -> return -1 // a < b + ai == null && bi == null -> return 0 // a = b + else -> return 1 // a < b + } + } + + return 0 + } + + companion object { + val ARTISTS: Comparator> = ListComparator(BasicComparator.ARTIST) + } + } + private class BasicComparator private constructor() : Comparator { override fun compare(a: T, b: T): Int { val aKey = a.collationKey @@ -382,7 +409,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { } } - class NullableComparator> private constructor() : Comparator { + private class NullableComparator> private constructor() : Comparator { override fun compare(a: T?, b: T?) = when { a != null && b != null -> a.compareTo(b) @@ -393,6 +420,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { companion object { val INT = NullableComparator() + val LONG = NullableComparator() val DATE = NullableComparator() } } @@ -403,7 +431,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { ByName.itemId -> ByName ByAlbum.itemId -> ByAlbum ByArtist.itemId -> ByArtist - ByYear.itemId -> ByYear + ByDate.itemId -> ByDate ByDuration.itemId -> ByDuration ByCount.itemId -> ByCount ByDisc.itemId -> ByDisc @@ -428,7 +456,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { Mode.ByName.intCode -> Mode.ByName Mode.ByArtist.intCode -> Mode.ByArtist Mode.ByAlbum.intCode -> Mode.ByAlbum - Mode.ByYear.intCode -> Mode.ByYear + Mode.ByDate.intCode -> Mode.ByDate Mode.ByDuration.intCode -> Mode.ByDuration Mode.ByCount.intCode -> Mode.ByCount Mode.ByDisc.intCode -> Mode.ByDisc diff --git a/app/src/main/java/org/oxycblt/auxio/music/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt new file mode 100644 index 000000000..cdb89d392 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt @@ -0,0 +1,293 @@ +/* + * 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.music + +import android.content.Context +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.inRangeOrNull +import org.oxycblt.auxio.util.nonZeroOrNull +import kotlin.math.max +import kotlin.math.min + +/** + * An ISO-8601/RFC 3339 Date. + * + * Unlike a typical Date within the standard library, this class just represents the ID3v2/Vorbis + * date format, which is largely assumed to be a subset of ISO-8601. No validation outside of format + * validation is done. + * + * 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 implementation is hidden. To instantiate one, use [from]. + * 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 code 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 = tokens[0] + + /** Resolve the year field in a way suitable for the UI. */ + fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year) + + private val month = tokens.getOrNull(1) + + private val day = tokens.getOrNull(2) + + private val hour = tokens.getOrNull(3) + + private val minute = tokens.getOrNull(4) + + private val second = tokens.getOrNull(5) + + override fun hashCode() = tokens.hashCode() + + override fun equals(other: Any?) = other is Date && tokens == other.tokens + + override fun compareTo(other: Date): Int { + for (i in 0 until max(tokens.size, other.tokens.size)) { + val ai = tokens.getOrNull(i) + val bi = other.tokens.getOrNull(i) + when { + ai != null && bi != null -> { + val result = ai.compareTo(bi) + if (result != 0) { + return result + } + } + ai == null && bi != null -> return -1 // a < b + ai == null && bi == null -> return 0 // a = b + else -> return 1 // a < b + } + } + + return 0 + } + + override fun toString() = StringBuilder().appendDate().toString() + + private fun StringBuilder.appendDate(): StringBuilder { + 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.append('Z')).toFixedString(2)}") + append(":${(second ?: return this.append('Z')).toFixedString(2)}") + return this.append('Z') + } + + 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 from(year: Int) = fromTokens(listOf(year)) + + fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) + + fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = + fromTokens(listOf(year, month, day, hour, minute)) + + fun from(timestamp: String): Date? { + val groups = + (ISO8601_REGEX.matchEntire(timestamp) ?: return null) + .groupValues + .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } + + return fromTokens(groups) + } + + private fun fromTokens(tokens: List): Date? { + val out = mutableListOf() + validateTokens(tokens, out) + if (out.isEmpty()) { + return null + } + + return Date(out) + } + + private fun validateTokens(src: List, dst: MutableList) { + dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) + dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) + dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) + dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return) + dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) + dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) + } + } +} + +/** + * Represents the type of release a particular album is. + * + * This can be used to differentiate between album sub-types like Singles, EPs, Compilations, and + * others. Internally, it operates on a reduced version of the MusicBrainz release type + * specification. It can be extended if there is demand. + * + * @author OxygenCobalt + */ +sealed class ReleaseType { + abstract val refinement: Refinement? + abstract val stringRes: Int + + data class Album(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_album + Refinement.LIVE -> R.string.lbl_album_live + Refinement.REMIX -> R.string.lbl_album_remix + } + } + + data class EP(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_ep + Refinement.LIVE -> R.string.lbl_ep_live + Refinement.REMIX -> R.string.lbl_ep_remix + } + } + + data class Single(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_single + Refinement.LIVE -> R.string.lbl_single_live + Refinement.REMIX -> R.string.lbl_single_remix + } + } + + data class Compilation(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = when (refinement) { + null -> R.string.lbl_compilation + Refinement.LIVE -> R.string.lbl_compilation_live + Refinement.REMIX -> R.string.lbl_compilation_remix + } + } + + object Soundtrack : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_soundtrack + } + + object Mix : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mix + } + + object Mixtape : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mixtape + } + + /** + * Roughly analogous to the MusicBrainz "live" and "remix" secondary types. Unlike the main + * types, these only modify an existing, primary type. They are not implemented for secondary + * types, however they may be expanded to compilations in the future. + */ + enum class Refinement { + LIVE, + REMIX + } + + companion object { + // Note: The parsing code is extremely clever in order to reduce duplication. It's + // better just to read the specification behind release types than follow this code. + + fun parse(types: List): ReleaseType? { + val primary = types.getOrNull(0) ?: return null + + // Primary types should be the first one in sequence. The spec makes no mention of + // whether primary types are a pre-requisite for secondary types, so we assume that + // it isn't. There are technically two other types, but those are unrelated to music + // and thus we don't support them. + return when { + primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } + primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } + primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } + else -> types.parseSecondaryTypes(0) { Album(it) } + } + } + + private inline fun List.parseSecondaryTypes( + secondaryIdx: Int, + convertRefinement: (Refinement?) -> ReleaseType + ): ReleaseType { + val secondary = getOrNull(secondaryIdx) + + return if (secondary.equals("compilation", true)) { + // Secondary type is a compilation, actually parse the third type + // and put that into a compilation if needed. + parseSecondaryTypeImpl(getOrNull(secondaryIdx + 1)) { Compilation(it) } + } else { + // Secondary type is a plain value, use the original values given. + parseSecondaryTypeImpl(secondary, convertRefinement) + } + } + + private inline fun parseSecondaryTypeImpl( + type: String?, + convertRefinement: (Refinement?) -> ReleaseType + ) = when { + // Parse all the types that have no children + type.equals("soundtrack", true) -> Soundtrack + type.equals("mixtape/street", true) -> Mixtape + type.equals("dj-mix", true) -> Mix + type.equals("live", true) -> convertRefinement(Refinement.LIVE) + type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + else -> convertRefinement(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 6e61913b8..1e0cb50ee 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 @@ -224,7 +224,7 @@ class Task(context: Context, private val raw: Song.Raw) { tags["TSOA"]?.let { raw.albumSortName = it[0] } // (Sort) Artist - tags["TPE1"]?.let { raw.artistNames = it } + (tags["TXXX:ARTISTS"] ?: tags["TPE1"])?.let { raw.artistNames = it } tags["TSOP"]?.let { raw.artistSortNames = it } // (Sort) Album artist diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt index 78d8c0ed9..cefa9f327 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt @@ -52,7 +52,6 @@ fun String.parseYear() = toIntOrNull()?.toDate() fun String.parseTimestamp() = Date.from(this) private val SEPARATOR_REGEX_CACHE = mutableMapOf() -private val ESCAPE_REGEX_CACHE = mutableMapOf() /** * Fully parse a multi-value tag. @@ -80,18 +79,10 @@ fun String.maybeParseSeparators(settings: Settings): List { // Try to cache compiled regexes for particular separator combinations. val regex = synchronized(SEPARATOR_REGEX_CACHE) { - SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[^\\\\][$separators]") } + SEPARATOR_REGEX_CACHE.getOrPut(separators) { Regex("[$separators]") } } - val escape = - synchronized(ESCAPE_REGEX_CACHE) { - ESCAPE_REGEX_CACHE.getOrPut(separators) { Regex("\\\\[$separators]") } - } - - return regex.split(this).map { value -> - // Convert escaped separators to their correct value - escape.replace(value) { match -> match.value.substring(1) }.trim() - } + return regex.split(this).map { it.trim() } } /** Parse a multi-value tag into a [ReleaseType], handling separators in the process. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt similarity index 55% rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt index 90e3b9aac..ebe2fa62d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt @@ -21,29 +21,29 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding -import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.recycler.DialogViewHolder import org.oxycblt.auxio.ui.recycler.ItemClickListener import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** - * The adapter that displays a list of genre choices in the picker UI. + * The adapter that displays a list of artist choices in the picker UI. */ -class GenreChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter() { - private var genres = listOf() +class ArtistChoiceAdapter(private val listener: ItemClickListener) : RecyclerView.Adapter() { + private var artists = listOf() - override fun getItemCount() = genres.size + override fun getItemCount() = artists.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - GenreChoiceViewHolder.new(parent) + ArtistChoiceViewHolder.new(parent) - override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) = - holder.bind(genres[position], listener) + override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) = + holder.bind(artists[position], listener) - fun submitList(newGenres: List) { - if (newGenres != genres) { - genres = newGenres + fun submitList(newArtists: List) { + if (newArtists != artists) { + artists = newArtists @Suppress("NotifyDataSetChanged") notifyDataSetChanged() @@ -52,20 +52,20 @@ class GenreChoiceAdapter(private val listener: ItemClickListener) : RecyclerView } /** - * The ViewHolder that displays a genre choice. Smaller than other parent items due to dialog + * The ViewHolder that displays a artist choice. Smaller than other parent items due to dialog * constraints. */ -class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) { - fun bind(genre: Genre, listener: ItemClickListener) { - binding.pickerImage.bind(genre) - binding.pickerName.text = genre.resolveName(binding.context) +class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : DialogViewHolder(binding.root) { + fun bind(artist: Artist, listener: ItemClickListener) { + binding.pickerImage.bind(artist) + binding.pickerName.text = artist.resolveName(binding.context) binding.root.setOnClickListener { - listener.onItemClick(genre) + listener.onItemClick(artist) } } companion object { fun new(parent: View) = - GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) + ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt similarity index 65% rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt index 59aa87d64..c5997ba68 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt @@ -26,7 +26,9 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment @@ -34,45 +36,42 @@ import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.ui.recycler.ItemClickListener import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A dialog that shows several genre options if the result of an genre-reliant operation is + * A dialog that shows several artist options if the result of an artist-reliant operation is * ambiguous. * @author OxygenCobalt + * + * TODO: Clean up the picker flow to reduce the amount of duplication I had to do. */ -class GenrePickerDialog : ViewBindingDialogFragment(), ItemClickListener { +class ArtistPickerDialog : ViewBindingDialogFragment(), ItemClickListener { private val pickerModel: PickerViewModel by viewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val navModel: NavigationViewModel by activityViewModels() - private val args: GenrePickerDialogArgs by navArgs() - private val adapter = GenreChoiceAdapter(this) + private val args: ArtistPickerDialogArgs by navArgs() + private val adapter = ArtistChoiceAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = DialogMusicPickerBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { builder - .setTitle( - when (args.pickerMode) { - PickerMode.GO -> R.string.lbl_go_genre - PickerMode.PLAY -> R.string.lbl_play_genre - } - ) + .setTitle(R.string.lbl_artists) .setNegativeButton(R.string.lbl_cancel, null) } override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) { - pickerModel.setSongUid(args.songUid) + pickerModel.setSongUid(args.uid) binding.pickerRecycler.adapter = adapter - collectImmediately(pickerModel.currentSong) { song -> - if (song != null) { - adapter.submitList(song.genres) - } else { - findNavController().navigateUp() + collectImmediately(pickerModel.currentItem) { item -> + when (item) { + is Song -> adapter.submitList(item.artists) + is Album -> adapter.submitList(item.artists) + null -> findNavController().navigateUp() + else -> error("Invalid datatype: ${item::class.java}") } } } @@ -82,13 +81,14 @@ class GenrePickerDialog : ViewBindingDialogFragment(), } override fun onItemClick(item: Item) { - check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" } + check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } findNavController().navigateUp() when (args.pickerMode) { - PickerMode.GO -> navModel.exploreNavigateTo(item) + PickerMode.SHOW -> navModel.exploreNavigateTo(item) PickerMode.PLAY -> { - val song = unlikelyToBeNull(pickerModel.currentSong.value) - playbackModel.playFromGenre(song, item) + val currentItem = pickerModel.currentItem.value + check(currentItem is Song) { "PickerMode.PLAY is only allowed with Songs" } + playbackModel.playFromArtist(currentItem, item) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt index 03cb26234..baf736214 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerMode.kt @@ -22,5 +22,5 @@ package org.oxycblt.auxio.music.picker */ enum class PickerMode { PLAY, - GO + SHOW } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt index 9dd32a185..db51bd0a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.picker import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song @@ -32,20 +33,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class PickerViewModel : ViewModel(), MusicStore.Callback { private val musicStore = MusicStore.getInstance() - private val _currentSong = MutableStateFlow(null) - val currentSong: StateFlow get() = _currentSong + private var _currentItem = MutableStateFlow(null) + val currentItem: StateFlow = _currentItem fun setSongUid(uid: Music.UID) { - if (_currentSong.value?.uid == uid) return + if (_currentItem.value?.uid == uid) return val library = unlikelyToBeNull(musicStore.library) - _currentSong.value = requireNotNull(library.find(uid)) { "Invalid song id provided" } + val item = requireNotNull(library.find(uid)) { "Invalid song id provided" } + _currentItem.value = item } override fun onLibraryChanged(library: MusicStore.Library?) { if (library != null) { - val song = _currentSong.value - if (song != null) { - _currentSong.value = library.sanitize(song) + when (val item = currentItem.value) { + is Song -> { + _currentItem.value = library.sanitize(item) + } + is Album -> { + _currentItem.value = library.sanitize(item) + } + null -> {} + else -> error("Invalid datatype: ${item::class.java}") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt index fcce67eae..1e0d3ba12 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/settings/SeparatorsDialog.kt @@ -52,7 +52,9 @@ class SeparatorsDialog : ViewBindingDialogFragment() { override fun onBindingCreated(binding: DialogSeparatorsBinding, savedInstanceState: Bundle?) { for (child in binding.separatorGroup.children) { - (child as MaterialCheckBox).isChecked = false + if (child is MaterialCheckBox) { + child.isChecked = false + } } settings.separators?.forEach { 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 acc36d14d..722c7ab37 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 @@ -30,6 +30,7 @@ import org.oxycblt.auxio.BuildConfig 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.MusicStore import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort @@ -223,7 +224,7 @@ class Indexer { val buildStart = System.currentTimeMillis() val albums = buildAlbums(songs) - val artists = buildArtists(albums) + val artists = buildArtists(songs, albums) val genres = buildGenres(songs) // Make sure we finalize all the items now that they are fully built. @@ -265,7 +266,7 @@ class Indexer { yield() // Note: We use a set here so we can eliminate effective duplicates of - // songs (by UID). + // songs (by UID) and sort to achieve consistent orderings val songs = mutableSetOf() val rawSongs = mutableListOf() @@ -280,12 +281,10 @@ class Indexer { metadataExtractor.finalize(rawSongs) - val sorted = Sort(Sort.Mode.ByName, true).songs(songs) - logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") // Ensure that sorting order is consistent so that grouping is also consistent. - return sorted + return Sort(Sort.Mode.ByName, true).songs(songs) } /** @@ -315,18 +314,26 @@ class Indexer { } /** - * Group up albums into artists. This also requires a de-duplication step due to some edge cases - * where [buildAlbums] could not detect duplicates. + * Group up songs AND albums into artists. This process seems weird (because it is), but + * the purpose is that the actual artist information of albums and songs often differs, + * and so they are linked in different ways. */ - private fun buildArtists(albums: List): List { - val artists = mutableListOf() - val albumsByArtist = albums.groupBy { it._rawArtist } - - for (entry in albumsByArtist) { - // The first album will suffice for template metadata. - artists.add(Artist(entry.key, entry.value)) + private fun buildArtists(songs: List, albums: List): List { + val musicByArtist = mutableMapOf>() + for (song in songs) { + for (rawArtist in song._rawArtists) { + musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) + } } + for (album in albums) { + for (rawArtist in album._rawArtists) { + musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) + } + } + + val artists = musicByArtist.map { Artist(it.key, it.value) } + logD("Successfully built ${artists.size} artists") return artists diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 14697035e..3c19f4d85 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -119,7 +119,7 @@ class PlaybackBarFragment : ViewBindingFragment() { val binding = requireBinding() binding.playbackCover.bind(song) binding.playbackSong.text = song.resolveName(context) - binding.playbackInfo.text = song.resolveIndividualArtistName(context) + binding.playbackInfo.text = song.resolveArtistContents(context) binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index f749a9e72..80cf949b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.msToDs +import org.oxycblt.auxio.music.picker.PickerMode import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.MainNavigationAction @@ -87,11 +88,11 @@ class PlaybackPanelFragment : } binding.playbackArtist.setOnClickListener { - playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) } + playbackModel.song.value?.let { showCurrentArtist() } } binding.playbackAlbum.setOnClickListener { - playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) } + playbackModel.song.value?.let { showCurrentAlbum() } } binding.playbackSeekBar.callback = this @@ -138,11 +139,11 @@ class PlaybackPanelFragment : true } R.id.action_go_artist -> { - playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) } + showCurrentArtist() true } R.id.action_go_album -> { - playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) } + showCurrentAlbum() true } R.id.action_song_detail -> { @@ -166,12 +167,11 @@ class PlaybackPanelFragment : private fun updateSong(song: Song?) { if (song == null) return - val binding = requireBinding() val context = requireContext() binding.playbackCover.bind(song) binding.playbackSong.text = song.resolveName(context) - binding.playbackArtist.text = song.resolveIndividualArtistName(context) + binding.playbackArtist.text = song.resolveArtistContents(context) binding.playbackAlbum.text = song.album.resolveName(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -179,7 +179,6 @@ class PlaybackPanelFragment : private fun updateParent(parent: MusicParent?) { val binding = requireBinding() val context = requireContext() - binding.playbackToolbar.subtitle = parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs) } @@ -202,4 +201,21 @@ class PlaybackPanelFragment : private fun updateShuffled(isShuffled: Boolean) { requireBinding().playbackShuffle.isActivated = isShuffled } + + private fun showCurrentArtist() { + val song = playbackModel.song.value ?: return + if (song.artists.size == 1) { + navModel.exploreNavigateTo(song.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(song.uid, PickerMode.SHOW) + ) + ) + } + } + private fun showCurrentAlbum() { + val song = playbackModel.song.value ?: return + navModel.exploreNavigateTo(song.album) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 2d18bb8f8..7d1053fe6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -106,12 +106,14 @@ class PlaybackViewModel(application: Application) : } /** Play a song from it's artist. */ - fun playFromArtist(song: Song) { - playbackManager.play(song, song.album.artist, settings, false) + fun playFromArtist(song: Song, artist: Artist) { + check(artist.songs.contains(song)) { "Invalid input: Artist is not linked to song" } + playbackManager.play(song, artist, settings, false) } /** Play a song from the specific genre that contains the song. */ fun playFromGenre(song: Song, genre: Genre) { + check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" } playbackManager.play(song, genre, settings, false) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 6fff33787..24ab49087 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -140,7 +140,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fun bind(item: Song, listener: QueueItemListener) { binding.songAlbumCover.bind(item) binding.songName.text = item.resolveName(binding.context) - binding.songInfo.text = item.resolveIndividualArtistName(binding.context) + binding.songInfo.text = item.resolveArtistContents(binding.context) binding.background.isInvisible = true 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 c0f557c91..09fa5884f 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 @@ -131,28 +131,30 @@ class MediaSessionComponent(private val context: Context, private val callback: // Note: We would leave the artist field null if it didn't exist and let downstream // consumers handle it, but that would break the notification display. val title = song.resolveName(context) - val artist = song.resolveIndividualArtistName(context) + val artist = song.resolveArtistContents(context) val builder = MediaMetadataCompat.Builder() .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.resolveName(context)) .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putText( MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, - song.album.artist.resolveName(context) + song.album.resolveArtistContents(context) ) .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) - .putText( - MediaMetadataCompat.METADATA_KEY_GENRE, - song.genres.joinToString { it.resolveName(context) } - ) .putText( METADATA_KEY_PARENT, parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs) ) + .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context)) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) + .putText( + MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, + parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs) + ) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) song.track?.let { @@ -202,7 +204,7 @@ class MediaSessionComponent(private val context: Context, private val callback: MediaDescriptionCompat.Builder() .setMediaId(song.uid.toString()) .setTitle(song.resolveName(context)) - .setSubtitle(song.resolveIndividualArtistName(context)) + .setSubtitle(song.resolveArtistContents(context)) .setIconUri(song.album.coverUri) .setMediaUri(song.uri) .build() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt index 4ffd4d134..86157ee5d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt @@ -73,9 +73,10 @@ class NotificationComponent(private val context: Context, sessionToken: MediaSes setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) // Starting in API 24, the subtext field changed semantics from being below the - // content text to being above the title. + // content text to being above the title. Use an appropriate field for both. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setSubText(metadata.getText(MediaSessionComponent.METADATA_KEY_PARENT)) + // Display description -> Parent in which playback is occurring + setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) } else { setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ALBUM)) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index a205b37b5..4eb03b6a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -153,16 +153,18 @@ class SearchFragment : is Song -> when (settings.libPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> if (item.genres.size > 1) { - navModel.mainNavigateTo( - MainNavigationAction.Directions( - MainFragmentDirections.showGenrePickerDialog(item.uid, PickerMode.PLAY) + MusicMode.ARTISTS -> { + if (item.artists.size == 1) { + playbackModel.playFromArtist(item, item.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(item.uid, PickerMode.PLAY) + ) ) - ) - } else { - playbackModel.playFromGenre(item, item.genres[0]) + } } + else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") } is MusicParent -> navModel.exploreNavigateTo(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 34fbc024b..a11451c55 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -82,8 +82,8 @@ class Settings(private val context: Context, private val callback: Callback? = n fun Int.migratePlaybackMode() = when (this) { - IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS - IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES + // Genre playback mode was retried in 3.0.0 + IntegerTable.PLAYBACK_MODE_ALL_SONGS, IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.SONGS IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS else -> null @@ -410,7 +410,7 @@ class Settings(private val context: Context, private val callback: Callback? = n Sort.fromIntCode( inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE) ) - ?: Sort(Sort.Mode.ByYear, false) + ?: Sort(Sort.Mode.ByDate, false) set(value) { inner.edit { putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt index 539bea579..c9cc298f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -64,7 +64,7 @@ class NavigationViewModel : ViewModel() { /** Navigate to an item's detail menu, whether a song/album/artist */ fun exploreNavigateTo(item: Music) { if (_exploreNavigationItem.value != null) { - logD("Already navigation, not doing explore action") + logD("Already navigating, not doing explore action") return } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt index 271638417..01b4eb301 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/fragment/MenuFragment.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.picker.PickerMode import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel @@ -65,7 +66,15 @@ abstract class MenuFragment : ViewBindingFragment() { requireContext().showToast(R.string.lng_queue_added) } R.id.action_go_artist -> { - navModel.exploreNavigateTo(song.album.artist) + if (song.artists.size == 1) { + navModel.exploreNavigateTo(song.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(song.uid, PickerMode.SHOW) + ) + ) + } } R.id.action_go_album -> { navModel.exploreNavigateTo(song.album) @@ -110,7 +119,15 @@ abstract class MenuFragment : ViewBindingFragment() { requireContext().showToast(R.string.lng_queue_added) } R.id.action_go_artist -> { - navModel.exploreNavigateTo(album.artist) + if (album.artists.size == 1) { + navModel.exploreNavigateTo(album.artists[0]) + } else { + navModel.mainNavigateTo( + MainNavigationAction.Directions( + MainFragmentDirections.actionPickArtist(album.uid, PickerMode.SHOW) + ) + ) + } } else -> { error("Unexpected menu item selected") diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt index 3cd73b9a8..cc7c5c98e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/recycler/ViewHolders.kt @@ -41,7 +41,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : fun bind(item: Song, listener: MenuItemListener) { binding.songAlbumCover.bind(item) binding.songName.text = item.resolveName(binding.context) - binding.songInfo.text = item.resolveIndividualArtistName(binding.context) + binding.songInfo.text = item.resolveArtistContents(binding.context) // binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) } binding.root.setOnLongClickListener { listener.onOpenMenu(item, it) @@ -79,7 +79,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding fun bind(item: Album, listener: MenuItemListener) { binding.parentImage.bind(item) binding.parentName.text = item.resolveName(binding.context) - binding.parentInfo.text = item.artist.resolveName(binding.context) + binding.parentInfo.text = item.resolveArtistContents(binding.context) // binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) } binding.root.setOnLongClickListener { listener.onOpenMenu(item, it) @@ -102,7 +102,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && - oldItem.artist.rawName == newItem.artist.rawName && + oldItem.areArtistContentsTheSame(newItem) && oldItem.releaseType == newItem.releaseType } } @@ -118,12 +118,18 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin fun bind(item: Artist, listener: MenuItemListener) { binding.parentImage.bind(item) binding.parentName.text = item.resolveName(binding.context) - binding.parentInfo.text = + + binding.parentInfo.text = if (item.songs.isNotEmpty()) { binding.context.getString( R.string.fmt_two, binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size) ) + } else { + // Artist has no songs, only display an album count. + binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size) + } + // binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) } binding.root.setOnLongClickListener { listener.onOpenMenu(item, it) 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 f4fba0968..74bdc0066 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/PrimitiveUtil.kt @@ -44,6 +44,9 @@ fun unlikelyToBeNull(value: T?) = /** Returns null if this value is 0. */ fun Int.nonZeroOrNull() = if (this > 0) this else null +/** Returns null if this value is 0. */ +fun Long.nonZeroOrNull() = if (this > 0) this else null + /** Returns null if this value is not in [range]. */ fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt index 715ad78ba..dda2ee12d 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/Forms.kt @@ -113,7 +113,7 @@ private fun RemoteViews.applyMeta( applyCover(context, state) setTextViewText(R.id.widget_song, state.song.resolveName(context)) - setTextViewText(R.id.widget_artist, state.song.resolveIndividualArtistName(context)) + setTextViewText(R.id.widget_artist, state.song.resolveArtistContents(context)) return this } diff --git a/app/src/main/res/layout/dialog_separators.xml b/app/src/main/res/layout/dialog_separators.xml index 05262399d..17740d56a 100644 --- a/app/src/main/res/layout/dialog_separators.xml +++ b/app/src/main/res/layout/dialog_separators.xml @@ -72,7 +72,14 @@ android:textAppearance="@style/TextAppearance.Auxio.BodyLarge" tools:ignore="RtlSymmetry" /> - + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index a7132ca06..e1e7c9f91 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -18,17 +18,18 @@ android:id="@+id/action_show_details" app:destination="@id/song_detail_dialog" /> + android:id="@+id/action_pick_artist" + app:destination="@id/artist_picker_dialog" /> + @string/set_playback_mode_all @string/set_playback_mode_artist @string/set_playback_mode_album - @string/set_playback_mode_genre - @integer/play_mode_songs - @integer/play_mode_artist - @integer/play_mode_album - @integer/play_mode_genre + @integer/music_mode_songs + @integer/music_mode_artist + @integer/music_mode_album @@ -95,15 +93,13 @@ @string/set_playback_mode_all @string/set_playback_mode_artist @string/set_playback_mode_album - @string/set_playback_mode_genre - @integer/play_mode_none - @integer/play_mode_songs - @integer/play_mode_artist - @integer/play_mode_album - @integer/play_mode_genre + @integer/music_mode_none + @integer/music_mode_songs + @integer/music_mode_artist + @integer/music_mode_album @@ -126,11 +122,10 @@ 0xA11A 0xA11B - -2147483648 - 0xA108 - 0xA109 - 0xA10A - 0xA10B + -2147483648 + 0xA109 + 0xA10A + 0xA10B 0xA111 0xA112 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9aa4809a..8f9c2e009 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,8 +105,6 @@ Go to genre Go to artist Go to album - Play from genre - Play from artist View properties Song properties @@ -182,7 +180,7 @@ Skip to next Repeat mode - @string/lbl_shuffle + @string/lbl_shuffle Use alternate notification action Prefer repeat mode action Prefer shuffle action @@ -206,8 +204,7 @@ Play from shown item Play from all songs Play from album - @string/lbl_play_artist - @string/lbl_play_genre + Play from artist Remember shuffle Keep shuffle on when playing a new song Rewind before skipping back @@ -237,7 +234,8 @@ Include Music will only be loaded from the folders you add. Multi-value separators - Configure the characters that denote multiple values in tags + Configure characters that denote multiple tag values + Warning: Using this setting may result in some tags being incorrectly interpreted as having multiple values. Comma (,) Semicolon (;) Slash (/) diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml index d6b9e7048..44372d190 100644 --- a/app/src/main/res/xml/prefs_main.xml +++ b/app/src/main/res/xml/prefs_main.xml @@ -91,7 +91,7 @@