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