From 48ad45e4c3bb6402cb6a01c9521dea2e7945798f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 6 Sep 2022 22:13:06 -0600 Subject: [PATCH] music: rework id system Completely rework the ID system to pave the way to MusicBrainz ID support and greatly increase ID integrity in general. This changeset removes the old ID field, an emulation of a polynomial hash that was used in all items, and replaces it with a new type called UID that is specific to Music. Other types just use plain equals now, and most instances of "id" to check for equality in the app have either been inlined into an equals override or removed outright. The new UID format is as follows: datatype/format:uuid Datatype is a tag that is just the lowercase tag name. For example, "song". Format is the program that created the UID. auxio will be an md5 hash, and musicbrainz will the a musicbrainz ID extracted from a file. UUID is the uuid itself. This is much more reliable and extendable than the old ID format. This will also be the last time I break compat with old ID formats. From now on, a legacy UID field will not be included to enable backwards compat, when the time comes for a breaking change. --- CHANGELOG.md | 3 + app/build.gradle | 1 + .../java/org/oxycblt/auxio/MainFragment.kt | 2 +- .../auxio/detail/AlbumDetailFragment.kt | 28 +- .../auxio/detail/ArtistDetailFragment.kt | 12 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 35 +- .../auxio/detail/GenreDetailFragment.kt | 12 +- .../oxycblt/auxio/detail/SongDetailDialog.kt | 2 +- .../detail/recycler/AlbumDetailAdapter.kt | 16 +- .../detail/recycler/ArtistDetailAdapter.kt | 16 +- .../auxio/detail/recycler/DetailAdapter.kt | 8 +- .../detail/recycler/GenreDetailAdapter.kt | 8 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 8 +- .../auxio/home/list/ArtistListFragment.kt | 1 + .../org/oxycblt/auxio/image/Components.kt | 16 +- .../java/org/oxycblt/auxio/music/Music.kt | 408 ++++++++++++------ .../org/oxycblt/auxio/music/MusicStore.kt | 38 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 22 +- .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../playback/state/PlaybackStateDatabase.kt | 77 ++-- .../playback/state/PlaybackStateManager.kt | 4 +- .../playback/system/MediaSessionComponent.kt | 2 +- .../auxio/playback/system/PlaybackService.kt | 2 +- .../org/oxycblt/auxio/search/SearchAdapter.kt | 12 +- .../oxycblt/auxio/search/SearchFragment.kt | 8 +- .../auxio/ui/BottomSheetContentBehavior.kt | 2 +- .../org/oxycblt/auxio/ui/recycler/Adapters.kt | 10 +- .../org/oxycblt/auxio/ui/recycler/Data.kt | 20 +- .../oxycblt/auxio/ui/recycler/ViewHolders.kt | 10 +- app/src/main/res/navigation/nav_explore.xml | 14 +- app/src/main/res/navigation/nav_main.xml | 4 +- 31 files changed, 443 insertions(+), 360 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc629e2a..5993a51fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +#### What's New +- Reworked music hashing to be even more reliable (Will wipe playback state) + #### What's Fixed - Fixed issue where the scroll popup would not display correctly in landscape mode [#230] - Fixed issue where the playback progress would continue in the notification even if diff --git a/app/build.gradle b/app/build.gradle index 04fb6ae11..f1907693a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" id "androidx.navigation.safeargs.kotlin" id "com.diffplug.spotless" + id "kotlin-parcelize" } android { diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index fdad06639..1fc5e403c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -225,7 +225,7 @@ class MainFragment : findNavController().navigate(MainFragmentDirections.actionShowAbout()) is MainNavigationAction.SongDetails -> findNavController() - .navigate(MainFragmentDirections.actionShowDetails(action.song.id)) + .navigate(MainFragmentDirections.actionShowDetails(action.song.uid)) } navModel.finishMainNavigation() 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 f85a3cd25..68bc7b228 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -74,7 +74,7 @@ class AlbumDetailFragment : override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - detailModel.setAlbumId(args.albumId) + detailModel.setAlbumUid(args.albumUid) binding.detailToolbar.apply { inflateMenu(R.menu.menu_album_detail) @@ -169,7 +169,7 @@ class AlbumDetailFragment : findNavController() .navigate( AlbumDetailFragmentDirections.actionShowArtist( - unlikelyToBeNull(detailModel.currentAlbum.value).artist.id)) + unlikelyToBeNull(detailModel.currentAlbum.value).artist.uid)) } private fun handleItemChange(album: Album?) { @@ -187,28 +187,28 @@ class AlbumDetailFragment : // Songs should be scrolled to if the album matches, or a new detail // fragment should be launched otherwise. is Song -> { - if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.album.id) { + if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) { logD("Navigating to a song in this album") - scrollToItem(item.id) + scrollToItem(item) navModel.finishExploreNavigation() } else { logD("Navigating to another album") findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)) + .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid)) } } // If the album matches, no need to do anything. Otherwise launch a new // detail fragment. is Album -> { - if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.id) { + if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) { logD("Navigating to the top of this album") binding.detailRecycler.scrollToPosition(0) navModel.finishExploreNavigation() } else { logD("Navigating to another album") findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id)) + .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid)) } } @@ -216,17 +216,17 @@ class AlbumDetailFragment : is Artist -> { logD("Navigating to another artist") findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id)) + .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid)) } null -> {} else -> error("Unexpected navigation item ${item::class.java}") } } - /** Scroll to an song using its [id]. */ - private fun scrollToItem(id: Long) { + /** Scroll to an [song]. */ + private fun scrollToItem(song: Song) { // Calculate where the item for the currently played song is - val pos = detailModel.albumData.value.indexOfFirst { it.id == id && it is Song } + val pos = detailModel.albumData.value.indexOf(song) if (pos != -1) { val binding = requireBinding() @@ -255,7 +255,7 @@ class AlbumDetailFragment : } } - if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) { + if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { detailAdapter.updateIndicator(song, isPlaying) } else { // Clear the ViewHolders if the mode isn't ALL_SONGS @@ -279,8 +279,6 @@ class AlbumDetailFragment : boxStart: Int, boxEnd: Int, snapPreference: Int - ): Int { - return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2) - } + ): Int = (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2) } } 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 3f6ef330a..6083c28cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -69,7 +69,7 @@ class ArtistDetailFragment : override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - detailModel.setArtistId(args.artistId) + detailModel.setArtistUid(args.artistUid) binding.detailToolbar.apply { inflateMenu(R.menu.menu_genre_artist_detail) @@ -177,22 +177,22 @@ class ArtistDetailFragment : is Song -> { logD("Navigating to another album") findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)) + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid)) } is Album -> { logD("Navigating to another album") findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.uid)) } is Artist -> { - if (item.id == detailModel.currentArtist.value?.id) { + if (item.uid == detailModel.currentArtist.value?.uid) { logD("Navigating to the top of this artist") binding.detailRecycler.scrollToPosition(0) navModel.finishExploreNavigation() } else { logD("Navigating to another artist") findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id)) + .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.uid)) } } null -> {} @@ -207,7 +207,7 @@ class ArtistDetailFragment : item = parent } - if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) { + if (parent is Artist && parent == unlikelyToBeNull(detailModel.currentArtist.value)) { item = song } 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 12f8a16af..fe72061cd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -109,10 +109,10 @@ class DetailViewModel(application: Application) : private val songGuard = TaskGuard() - fun setSongId(id: Long) { - if (_currentSong.value?.run { song.id } == id) return + fun setSongUid(uid: Music.UID) { + if (_currentSong.value?.run { song.uid } == uid) return val library = unlikelyToBeNull(musicStore.library) - val song = requireNotNull(library.findSongById(id)) { "Invalid song id provided" } + val song = requireNotNull(library.find(uid)) { "Invalid song id provided" } generateDetailSong(song) } @@ -121,27 +121,28 @@ class DetailViewModel(application: Application) : _currentSong.value = null } - fun setAlbumId(id: Long) { - if (_currentAlbum.value?.id == id) return + fun setAlbumUid(uid: Music.UID) { + if (_currentAlbum.value?.uid == uid) return val library = unlikelyToBeNull(musicStore.library) - val album = requireNotNull(library.findAlbumById(id)) { "Invalid album id provided " } + val album = requireNotNull(library.find(uid)) { "Invalid album id provided " } _currentAlbum.value = album refreshAlbumData(album) } - fun setArtistId(id: Long) { - if (_currentArtist.value?.id == id) return + fun setArtistUid(uid: Music.UID) { + logD(uid) + if (_currentArtist.value?.uid == uid) return val library = unlikelyToBeNull(musicStore.library) - val artist = requireNotNull(library.findArtistById(id)) { "Invalid artist id provided" } + val artist = requireNotNull(library.find(uid)) { "Invalid artist id provided" } _currentArtist.value = artist refreshArtistData(artist) } - fun setGenreId(id: Long) { - if (_currentGenre.value?.id == id) return + fun setGenreUid(uid: Music.UID) { + if (_currentGenre.value?.uid == uid) return val library = unlikelyToBeNull(musicStore.library) - val genre = requireNotNull(library.findGenreById(id)) { "Invalid genre id provided" } + val genre = requireNotNull(library.find(uid)) { "Invalid genre id provided" } _currentGenre.value = genre refreshGenreData(genre) } @@ -318,12 +319,6 @@ class DetailViewModel(application: Application) : } } -data class SortHeader(@StringRes val string: Int) : Item() { - override val id: Long - get() = string.toLong() -} +data class SortHeader(@StringRes val string: Int) : Item -data class DiscHeader(val disc: Int) : Item() { - override val id: Long - get() = disc.toLong() -} +data class DiscHeader(val disc: Int) : Item 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 f59d5472b..cf190f591 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -70,7 +70,7 @@ class GenreDetailFragment : override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - detailModel.setGenreId(args.genreId) + detailModel.setGenreUid(args.genreUid) binding.detailToolbar.apply { inflateMenu(R.menu.menu_genre_artist_detail) @@ -125,6 +125,7 @@ class GenreDetailFragment : override fun onOpenMenu(item: Item, anchor: View) { if (item is Song) { musicMenu(anchor, R.menu.menu_song_actions, item) + return } error("Unexpected datatype when opening menu: ${item::class.java}") @@ -170,16 +171,17 @@ class GenreDetailFragment : is Song -> { logD("Navigating to another song") findNavController() - .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id)) + .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid)) } is Album -> { logD("Navigating to another album") - findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id)) + findNavController() + .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.uid)) } is Artist -> { logD("Navigating to another artist") findNavController() - .navigate(GenreDetailFragmentDirections.actionShowArtist(item.id)) + .navigate(GenreDetailFragmentDirections.actionShowArtist(item.uid)) } is Genre -> { navModel.finishExploreNavigation() @@ -189,7 +191,7 @@ class GenreDetailFragment : } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) { + if (parent is Genre && parent == unlikelyToBeNull(detailModel.currentGenre.value)) { detailAdapter.updateIndicator(song, isPlaying) } else { // Ignore song playback not from the genre diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index a49d94374..4b67fa68e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -50,7 +50,7 @@ class SongDetailDialog : ViewBindingDialogFragment() { override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - detailModel.setSongId(args.songId) + detailModel.setSongUid(args.songUid) collectImmediately(detailModel.currentSong, ::updateSong) } 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 4070f4950..fed654348 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 @@ -85,15 +85,15 @@ class AlbumDetailAdapter(private val listener: Listener) : companion object { private val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Album && newItem is Album -> - AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + AlbumDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is DiscHeader && newItem is DiscHeader -> - DiscHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + DiscHeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) - else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem) + AlbumSongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) } } } @@ -142,7 +142,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Album, newItem: Album) = + override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.artist.rawName == newItem.artist.rawName && oldItem.date == newItem.date && @@ -168,7 +168,7 @@ class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = + override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = oldItem.disc == newItem.disc } } @@ -216,7 +216,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = + override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && 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 9f1357761..98cb49071 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 @@ -87,15 +87,15 @@ class ArtistDetailAdapter(private val listener: Listener) : companion object { private val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Artist && newItem is Artist -> - ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + ArtistDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Album && newItem is Album -> - ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + ArtistAlbumViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) - else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem) + ArtistSongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) } } } @@ -112,7 +112,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It // Get the genre that corresponds to the most songs in this artist, which would be // the most "Prominent" genre. - var genresByAmount = mutableMapOf() + val genresByAmount = mutableMapOf() for (song in item.songs) { for (genre in song.genres) { genresByAmount[genre] = genresByAmount[genre]?.inc() ?: 1 @@ -172,7 +172,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Album, newItem: Album) = + override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.date == newItem.date } } @@ -207,7 +207,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = + override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.album.rawName == newItem.album.rawName } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 5fd7c8f82..088acfa15 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -95,12 +95,12 @@ abstract class DetailAdapter( companion object { val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + HeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is SortHeader && newItem is SortHeader -> - SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + SortHeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) else -> false } } @@ -132,7 +132,7 @@ class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) = + override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) = oldItem.string == newItem.string } } 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 1121874a6..c3c3c78a8 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 @@ -79,12 +79,12 @@ class GenreDetailAdapter(private val listener: Listener) : companion object { val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { + override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Genre && newItem is Genre -> - GenreDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + GenreDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - SongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + SongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) } } @@ -113,7 +113,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = + override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size && oldItem.durationMs == newItem.durationMs diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index af7135d72..9ea86f90c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -372,10 +372,10 @@ class HomeFragment : ViewBindingFragment(), Toolbar.OnMenuI private fun handleNavigation(item: Music?) { val action = when (item) { - is Song -> HomeFragmentDirections.actionShowAlbum(item.album.id) - is Album -> HomeFragmentDirections.actionShowAlbum(item.id) - is Artist -> HomeFragmentDirections.actionShowArtist(item.id) - is Genre -> HomeFragmentDirections.actionShowGenre(item.id) + is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid) + is Album -> HomeFragmentDirections.actionShowAlbum(item.uid) + is Artist -> HomeFragmentDirections.actionShowArtist(item.uid.also { logD(it) }) + is Genre -> HomeFragmentDirections.actionShowGenre(item.uid) else -> return } 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 31a1d9dff..1be6155cb 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 @@ -81,6 +81,7 @@ class ArtistListFragment : HomeListFragment() { override fun onOpenMenu(item: Item, anchor: View) { if (item is Artist) { musicMenu(anchor, R.menu.menu_genre_artist_actions, item) + return } error("Unexpected datatype when opening menu: ${item::class.java}") diff --git a/app/src/main/java/org/oxycblt/auxio/image/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/Components.kt index 00d06fe67..094093408 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/Components.kt @@ -39,14 +39,13 @@ import org.oxycblt.auxio.ui.Sort /** A basic keyer for music data. */ class MusicKeyer : Keyer { - override fun key(data: Music, options: Options): String { - return if (data is Song) { + override fun key(data: Music, options: Options) = + if (data is Song) { // Group up song covers with album covers for better caching - key(data.album, options) + data.album.uid.toString() } else { - "${data::class.simpleName}: ${data.id}" + data.uid.toString() } - } } /** @@ -65,9 +64,8 @@ private constructor(private val context: Context, private val album: Album) : Ba } class SongFactory : Fetcher.Factory { - override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher { - return AlbumCoverFetcher(options.context, data.album) - } + override fun create(data: Song, options: Options, imageLoader: ImageLoader) = + AlbumCoverFetcher(options.context, data.album) } class AlbumFactory : Fetcher.Factory { @@ -114,7 +112,7 @@ private constructor( // 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.id }.keys + 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) { 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 ab3ff58fe..890ad4f28 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -20,20 +20,30 @@ package org.oxycblt.auxio.music import android.content.Context +import android.os.Parcelable +import java.security.MessageDigest +import java.util.UUID import kotlin.math.max import kotlin.math.min +import kotlin.reflect.KClass +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.recycler.Item import org.oxycblt.auxio.util.inRangeOrNull +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.msToSecs import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull // --- MUSIC MODELS --- /** [Item] variant that represents a music item. */ -sealed class Music : Item() { +sealed class Music : Item { + abstract val uid: UID + /** The raw name of this item. Null if unknown. */ abstract val rawName: String? @@ -52,6 +62,106 @@ sealed class Music : Item() { * become Unknown Artist, (124) would become its proper genre name, etc. */ abstract fun resolveName(context: Context): String + + // Equality is based on UIDs, as some items (Especially artists) can have identical + // properties (Name) yet non-identical UIDs due to MusicBrainz tags + + override fun hashCode() = uid.hashCode() + + override fun equals(other: Any?) = + other is Music && javaClass == other.javaClass && uid == other.uid + + /** A unique identifier for a piece of music. */ + @Parcelize + class UID + private constructor(val datatype: String, val isMusicBrainz: Boolean, val uuid: UUID) : + Parcelable { + @IgnoredOnParcel private val hashCode: Int + + init { + var result = datatype.hashCode() + result = 31 * result + isMusicBrainz.hashCode() + result = 31 * result + uuid.hashCode() + hashCode = result + } + + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is UID && + datatype == other.datatype && + isMusicBrainz == other.isMusicBrainz && + uuid == other.uuid + + override fun toString() = "$datatype/${if (isMusicBrainz) "musicbrainz" else "auxio"}:$uuid" + + companion object { + fun fromString(uid: String): UID? { + val split = uid.split(':', limit = 2) + if (split.size != 2) { + logE("Invalid uid: Malformed structure") + } + + val namespace = split[0].split('/', limit = 2) + if (namespace.size != 2) { + logE("Invalid uid: Malformed namespace") + return null + } + + val datatype = namespace[0] + val isMusicBrainz = + when (namespace[1]) { + "auxio" -> false + "musicbrainz" -> true + else -> { + logE("Invalid mid: Malformed uuid type") + return null + } + } + + val uuid = + try { + UUID.fromString(split[1]) + } catch (e: Exception) { + logE("Invalid uid: Malformed UUID") + return null + } + + return UID(datatype, isMusicBrainz, uuid) + } + + fun hashed(clazz: KClass<*>, updates: MessageDigest.() -> Unit): UID { + val digest = MessageDigest.getInstance("MD5") + updates(digest) + + val hash = digest.digest() + val uuid = + UUID( + hash[0] + .toLong() + .shl(56) + .or(hash[1].toLong().and(0xFF).shl(48)) + .or(hash[2].toLong().and(0xFF).shl(40)) + .or(hash[3].toLong().and(0xFF).shl(32)) + .or(hash[4].toLong().and(0xFF).shl(24)) + .or(hash[5].toLong().and(0xFF).shl(16)) + .or(hash[6].toLong().and(0xFF).shl(8)) + .or(hash[7].toLong().and(0xFF)), + hash[8] + .toLong() + .shl(56) + .or(hash[9].toLong().and(0xFF).shl(48)) + .or(hash[10].toLong().and(0xFF).shl(40)) + .or(hash[11].toLong().and(0xFF).shl(32)) + .or(hash[12].toLong().and(0xFF).shl(24)) + .or(hash[13].toLong().and(0xFF).shl(16)) + .or(hash[14].toLong().and(0xFF).shl(8)) + .or(hash[15].toLong().and(0xFF))) + + return UID(unlikelyToBeNull(clazz.simpleName).lowercase(), false, uuid) + } + } + } } /** @@ -67,17 +177,8 @@ sealed class MusicParent : Music() { * A song. * @author OxygenCobalt */ -data class Song(private val raw: Raw) : Music() { - override val id: Long - get() { - var result = rawName.toMusicId() - result = 31 * result + album.rawName.toMusicId() - result = 31 * result + album.artist.rawName.toMusicId() - result = 31 * result + (track ?: 0) - result = 31 * result + (disc ?: 0) - result = 31 * result + durationMs - return result - } +class Song(private val raw: Raw) : Music() { + override val uid: UID override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" } @@ -113,20 +214,18 @@ data class Song(private val raw: Raw) : Music() { val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" } /** The track number of this song in it's album.. */ - val track = raw.track + val track: Int? = raw.track /** The disc number of this song in it's album. */ - val disc = raw.disc + val disc: Int? = raw.disc private var _album: Album? = null /** The album of this song. */ val album: Album get() = unlikelyToBeNull(_album) - private var _genres: MutableList = mutableListOf() - /** The genre of this song. Will be an "unknown genre" if the song does not have any. */ - val genres: List - get() = _genres + // TODO: Multi-artist support + // private val _artists: MutableList = mutableListOf() /** * The raw artist name for this song in particular. First uses the artist tag, and then falls @@ -142,31 +241,29 @@ data class Song(private val raw: Raw) : Music() { fun resolveIndividualArtistName(context: Context) = raw.artistName ?: album.artist.resolveName(context) + private val _genres: MutableList = mutableListOf() + /** The genre of this song. Will be an "unknown genre" if the song does not have any. */ + val genres: List + get() = _genres + // --- INTERNAL FIELDS --- - val _distinct = - rawName to - raw.albumName to - raw.artistName to - raw.albumArtistName to - raw.genreNames to - track to - disc to - durationMs - - val _rawAlbum: Album.Raw + 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, + date = raw.date, + releaseType = raw.albumReleaseType, + rawArtist = + if (raw.albumArtistName != null) { + Artist.Raw(raw.albumArtistName, raw.albumArtistSortName) + } else { + Artist.Raw(raw.artistName, raw.artistSortName) + }) val _rawGenres = raw.genreNames?.map { Genre.Raw(it) } ?: listOf(Genre.Raw(null)) - val _isMissingAlbum: Boolean - get() = _album == null - - val _isMissingArtist: Boolean - get() = _album?._isMissingArtist ?: true - - val _isMissingGenre: Boolean - get() = _genres.isEmpty() - fun _link(album: Album) { _album = album } @@ -175,27 +272,28 @@ data class Song(private val raw: Raw) : Music() { _genres.add(genre) } + fun _validate() { + (checkNotNull(_album) { "Malformed song: album is null" })._validate() + check(_genres.isNotEmpty()) { "Malformed song: genres are empty" } + } + init { - val artistName: String? - val artistSortName: String? + // Generally, we calculate UIDs at the end since everything will definitely be initialized + // by now. + uid = + UID.hashed(this::class) { + update(rawName.lowercase()) + update(_rawAlbum.name.lowercase()) + update(_rawAlbum.date) - if (raw.albumArtistName != null) { - artistName = raw.albumArtistName - artistSortName = raw.albumArtistSortName - } else { - artistName = raw.artistName - artistSortName = raw.artistSortName - } + update(raw.artistName) + update(raw.albumArtistName) - _rawAlbum = - Album.Raw( - mediaStoreId = raw.albumMediaStoreId, - name = raw.albumName, - sortName = raw.albumSortName, - date = raw.date, - releaseType = raw.albumReleaseType, - artistName, - artistSortName) + update(track) + update(disc) + + update(durationMs.msToSecs()) + } } data class Raw( @@ -225,43 +323,26 @@ data class Song(private val raw: Raw) : Music() { } /** The data object for an album. */ -data class Album(private val raw: Raw, override val songs: List) : MusicParent() { - init { - for (song in songs) { - song._link(this) - } - } +class Album(raw: Raw, override val songs: List) : MusicParent() { + override val uid: UID - override val id: Long - get() { - var result = rawName.toMusicId() - result = 31 * result + artist.rawName.toMusicId() - result = 31 * result + (date?.year ?: 0) - return result - } - - override val rawName = requireNotNull(raw.name) { "Invalid raw: No name" } + override val rawName = raw.name override val rawSortName = raw.sortName override fun resolveName(context: Context) = rawName - /** - * The album cover URI for this album. Usually low quality, so using Coil is recommended - * instead. - */ - val coverUri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.albumCoverUri - /** The latest date this album was released. */ val date = raw.date /** The release type of this album, such as "EP". Defaults to "Album". */ val releaseType = raw.releaseType ?: ReleaseType.Album(null) - private var _artist: Artist? = null - /** The parent artist of this album. */ - val artist: Artist - get() = unlikelyToBeNull(_artist) + /** + * The album cover URI for this album. Usually low quality, so using Coil is recommended + * instead. + */ + val coverUri = raw.mediaStoreId.albumCoverUri /** The earliest date a song in this album was added. */ val dateAdded = songs.minOf { it.dateAdded } @@ -269,34 +350,50 @@ data class Album(private val raw: Raw, override val songs: List) : MusicPa /** The total duration of songs in this album, in millis. */ val durationMs = songs.sumOf { it.durationMs } + private var _artist: Artist? = null + /** The parent artist of this album. */ + val artist: Artist + get() = unlikelyToBeNull(_artist) + // --- INTERNAL FIELDS --- - val _rawArtist: Artist.Raw - get() = Artist.Raw(name = raw.artistName, sortName = raw.artistSortName) - - val _isMissingArtist: Boolean - get() = _artist == null + val _rawArtist = raw.rawArtist fun _link(artist: Artist) { _artist = artist } - data class Raw( - val mediaStoreId: Long?, - val name: String?, + fun _validate() { + checkNotNull(_artist) { "Invalid album: Artist is null " } + } + + init { + uid = + UID.hashed(this::class) { + update(rawName) + update(_rawArtist.name) + update(date) + } + + for (song in songs) { + song._link(this) + } + } + + class Raw( + val mediaStoreId: Long, + val name: String, val sortName: String?, val date: Date?, val releaseType: ReleaseType?, - val artistName: String?, - val artistSortName: String?, + val rawArtist: Artist.Raw ) { - val groupingId: Long + private val hashCode = 31 * name.lowercase().hashCode() + rawArtist.hashCode() - init { - var groupingIdResult = artistName.toMusicId() - groupingIdResult = 31 * groupingIdResult + name.toMusicId() - groupingId = groupingIdResult - } + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Raw && name.equals(other.name, true) && rawArtist == other.rawArtist } } @@ -304,19 +401,12 @@ data class Album(private val raw: Raw, override val songs: List) : MusicPa * The [MusicParent] for an *album* artist. This reflects a group of songs with the same(ish) album * artist or artist field, not the individual performers of an artist. */ -data class Artist( - private val raw: Raw, +class Artist( + raw: Raw, /** The albums of this artist. */ val albums: List ) : MusicParent() { - init { - for (album in albums) { - album._link(this) - } - } - - override val id: Long - get() = rawName.toMusicId() + override val uid: UID override val rawName = raw.name @@ -329,59 +419,93 @@ data class Artist( /** The total duration of songs in this artist, in millis. */ val durationMs = songs.sumOf { it.durationMs } - data class Raw(val name: String?, val sortName: String?) { - val groupingId = name.toMusicId() + init { + uid = UID.hashed(this::class) { update(rawName) } + + for (album in albums) { + album._link(this) + } + } + + class Raw(val name: String?, val sortName: String?) { + private val hashCode = name?.lowercase().hashCode() + + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Raw && + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } } } /** The data object for a genre. */ -data class Genre(private val raw: Raw, override val songs: List) : MusicParent() { - init { - for (song in songs) { - song._link(this) - } - } +class Genre(raw: Raw, override val songs: List) : MusicParent() { + override val uid: UID - override val rawName: String? - get() = raw.name + override val rawName = raw.name // Sort tags don't make sense on genres - override val rawSortName: String? - get() = rawName - - override val id: Long - get() = rawName.toMusicId() + override val rawSortName = rawName override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) /** The total duration of the songs in this genre, in millis. */ val durationMs = songs.sumOf { it.durationMs } - data class Raw(val name: String?) { - override fun equals(other: Any?): Boolean { - if (other !is Raw) return false - return when { - name != null && other.name != null -> name.equals(other.name, true) - name == null && other.name == null -> true - else -> false - } - } + init { + uid = UID.hashed(this::class) { update(rawName) } - override fun hashCode() = name?.lowercase().hashCode() + for (song in songs) { + song._link(this) + } + } + + class Raw(val name: String?) { + private val hashCode = name?.lowercase().hashCode() + + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Raw && + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } } } -private fun String?.toMusicId(): Long { - if (this == null) { - // Pre-calculated hash of MediaStore.UNKNOWN_STRING - return 54493231833456 - } +fun MessageDigest.update(string: String?) { + if (string == null) return + update(string.lowercase().toByteArray()) +} - var result = 0L - for (ch in lowercase()) { - result = 31 * result + ch.lowercaseChar().code - } - return result +fun MessageDigest.update(date: Date?) { + if (date == null) return + update(date.toString().toByteArray()) +} + +fun MessageDigest.update(n: Int?) { + if (n == null) return + update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) +} + +fun MessageDigest.update(n: Long?) { + if (n == null) return + update( + byteArrayOf( + n.toByte(), + n.shr(8).toByte(), + n.shr(16).toByte(), + n.shr(24).toByte(), + n.shr(32).toByte(), + n.shr(40).toByte(), + n.shr(56).toByte(), + n.shr(64).toByte())) } /** 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 9e3ce034d..b3650ac0d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -75,34 +75,38 @@ class MusicStore private constructor() { val albums: List, val songs: List ) { - private val genreIdMap = HashMap().apply { genres.forEach { put(it.id, it) } } - private val artistIdMap = - HashMap().apply { artists.forEach { put(it.id, it) } } - private val albumIdMap = HashMap().apply { albums.forEach { put(it.id, it) } } - private val songIdMap = HashMap().apply { songs.forEach { put(it.id, it) } } + private val uidMap = HashMap() - /** Find a [Song] by it's ID. Null if no song exists with that ID. */ - fun findSongById(songId: Long) = songIdMap[songId] + init { + for (song in songs) { + uidMap[song.uid] = song + } - /** Find a [Album] by it's ID. Null if no album exists with that ID. */ - fun findAlbumById(albumId: Long) = albumIdMap[albumId] + for (album in albums) { + uidMap[album.uid] = album + } - /** Find a [Artist] by it's ID. Null if no artist exists with that ID. */ - fun findArtistById(artistId: Long) = artistIdMap[artistId] + for (artist in artists) { + uidMap[artist.uid] = artist + } - /** Find a [Genre] by it's ID. Null if no genre exists with that ID. */ - fun findGenreById(genreId: Long) = genreIdMap[genreId] + for (genre in genres) { + uidMap[genre.uid] = genre + } + } + + @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID): T? = uidMap[uid] as? T /** Sanitize an old item to find the corresponding item in a new library. */ - fun sanitize(song: Song) = findSongById(song.id) + fun sanitize(song: Song) = find(song.uid) /** Sanitize an old item to find the corresponding item in a new library. */ fun sanitize(songs: List) = songs.mapNotNull { sanitize(it) } /** Sanitize an old item to find the corresponding item in a new library. */ - fun sanitize(album: Album) = findAlbumById(album.id) + fun sanitize(album: Album) = find(album.uid) /** Sanitize an old item to find the corresponding item in a new library. */ - fun sanitize(artist: Artist) = findArtistById(artist.id) + fun sanitize(artist: Artist) = find(artist.uid) /** Sanitize an old item to find the corresponding item in a new library. */ - fun sanitize(genre: Genre) = findGenreById(genre.id) + fun sanitize(genre: Genre) = find(genre.uid) /** Find a song for a [uri]. */ fun findSongForUri(context: Context, uri: Uri) = 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 577730571..8d9f3010d 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 @@ -229,13 +229,7 @@ class Indexer { // Sanity check: Ensure that all songs are linked up to albums/artists/genres. for (song in songs) { - if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) { - error( - "Found unlinked song: ${song.rawName} [" + - "missing album: ${song._isMissingAlbum} " + - "missing artist: ${song._isMissingArtist} " + - "missing genre: ${song._isMissingGenre}]") - } + song._validate() } logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") @@ -262,7 +256,7 @@ class Indexer { } // Deduplicate songs to prevent (most) deformed music clones - songs = songs.distinctBy { it._distinct }.toMutableList() + songs = songs.distinctBy { it.uid }.toMutableList() // Ensure that sorting order is consistent so that grouping is also consistent. Sort(Sort.Mode.ByName, true).songsInPlace(songs) @@ -287,7 +281,7 @@ class Indexer { */ private fun buildAlbums(songs: List): List { val albums = mutableListOf() - val songsByAlbum = songs.groupBy { it._rawAlbum.groupingId } + val songsByAlbum = songs.groupBy { it._rawAlbum } for (entry in songsByAlbum) { val albumSongs = entry.value @@ -296,8 +290,7 @@ class Indexer { // This allows us to replicate the LAST_YEAR field, which is useful as it means that // weird years like "0" wont show up if there are alternatives. val templateSong = - albumSongs.maxWith( - compareBy(Sort.Mode.NullableComparator.DATE) { it._rawAlbum.date }) + albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { entry.key.date }) albums.add(Album(templateSong._rawAlbum, albumSongs)) } @@ -313,12 +306,11 @@ class Indexer { */ private fun buildArtists(albums: List): List { val artists = mutableListOf() - val albumsByArtist = albums.groupBy { it._rawArtist.groupingId } + val albumsByArtist = albums.groupBy { it._rawArtist } for (entry in albumsByArtist) { // The first album will suffice for template metadata. - val templateAlbum = entry.value[0] - artists.add(Artist(templateAlbum._rawArtist, albums = entry.value)) + artists.add(Artist(entry.key, entry.value)) } logD("Successfully built ${artists.size} artists") @@ -340,7 +332,7 @@ class Indexer { } for (entry in songsByGenre) { - genres.add(Genre(entry.key, songs = entry.value)) + genres.add(Genre(entry.key, entry.value)) } logD("Successfully built ${genres.size} genres") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 18b9f5895..64069c980 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -84,7 +84,7 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() { // User wants album gain to be used when in an album, track gain otherwise. ReplayGainMode.DYNAMIC -> playbackManager.parent is Album && - playbackManager.song?.album?.id == playbackManager.parent?.id + playbackManager.song?.album == playbackManager.parent } val resolvedGain = diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index e993229c0..bc6aa419a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -21,11 +21,8 @@ import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -import androidx.core.database.getLongOrNull import androidx.core.database.sqlite.transaction -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.MusicParent import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song @@ -78,11 +75,10 @@ class PlaybackStateDatabase private constructor(context: Context) : private fun constructStateTable(command: StringBuilder): StringBuilder { command .append("${StateColumns.COLUMN_ID} LONG PRIMARY KEY,") - .append("${StateColumns.COLUMN_SONG_ID} LONG,") + .append("${StateColumns.COLUMN_SONG_UID} STRING,") .append("${StateColumns.COLUMN_POSITION} LONG NOT NULL,") - .append("${StateColumns.COLUMN_PARENT_ID} LONG,") + .append("${StateColumns.COLUMN_PARENT_UID} STRING,") .append("${StateColumns.COLUMN_INDEX} INTEGER NOT NULL,") - .append("${StateColumns.COLUMN_PLAYBACK_MODE} INTEGER NOT NULL,") .append("${StateColumns.COLUMN_IS_SHUFFLED} BOOLEAN NOT NULL,") .append("${StateColumns.COLUMN_REPEAT_MODE} INTEGER NOT NULL)") @@ -93,8 +89,7 @@ class PlaybackStateDatabase private constructor(context: Context) : private fun constructQueueTable(command: StringBuilder): StringBuilder { command .append("${QueueColumns.ID} LONG PRIMARY KEY,") - .append("${QueueColumns.SONG_ID} INTEGER NOT NULL,") - .append("${QueueColumns.ALBUM_ID} INTEGER NOT NULL)") + .append("${QueueColumns.SONG_UID} STRING NOT NULL)") return command } @@ -109,17 +104,12 @@ class PlaybackStateDatabase private constructor(context: Context) : // Correct the index to match up with a possibly shortened queue (file removals/changes) var actualIndex = rawState.index - while (queue.getOrNull(actualIndex)?.id != rawState.songId && actualIndex > -1) { + while (queue.getOrNull(actualIndex)?.uid?.also { logD(it) } != rawState.songUid && + actualIndex > -1) { actualIndex-- } - val parent = - when (rawState.playbackMode) { - PlaybackMode.ALL_SONGS -> null - PlaybackMode.IN_ALBUM -> rawState.parentId?.let(library::findAlbumById) - PlaybackMode.IN_ARTIST -> rawState.parentId?.let(library::findArtistById) - PlaybackMode.IN_GENRE -> rawState.parentId?.let(library::findGenreById) - } + val parent = rawState.parentUid?.let { library.find(it) } return SavedState( index = actualIndex, @@ -140,11 +130,10 @@ class PlaybackStateDatabase private constructor(context: Context) : val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_INDEX) val posIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_POSITION) - val playbackModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PLAYBACK_MODE) val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_REPEAT_MODE) val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_IS_SHUFFLED) - val songIdIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_SONG_ID) - val parentIdIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PARENT_ID) + val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_SONG_UID) + val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.COLUMN_PARENT_UID) cursor.moveToFirst() @@ -154,10 +143,9 @@ class PlaybackStateDatabase private constructor(context: Context) : repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) ?: RepeatMode.NONE, isShuffled = cursor.getInt(shuffleIndex) == 1, - songId = cursor.getLong(songIdIndex), - parentId = cursor.getLongOrNull(parentIdIndex), - playbackMode = PlaybackMode.fromInt(cursor.getInt(playbackModeIndex)) - ?: PlaybackMode.ALL_SONGS) + songUid = Music.UID.fromString(cursor.getString(songUidIndex)) + ?: return@queryAll null, + parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) } } @@ -168,9 +156,12 @@ class PlaybackStateDatabase private constructor(context: Context) : readableDatabase.queryAll(TABLE_NAME_QUEUE) { cursor -> if (cursor.count == 0) return@queryAll - val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_ID) + val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) while (cursor.moveToNext()) { - library.findSongById(cursor.getLong(songIndex))?.let(queue::add) + logD(cursor.getString(songIndex)) + val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue + val song = library.find(uid) ?: continue + queue.add(song) } } @@ -190,15 +181,8 @@ class PlaybackStateDatabase private constructor(context: Context) : positionMs = state.positionMs, repeatMode = state.repeatMode, isShuffled = state.isShuffled, - songId = state.queue[state.index].id, - parentId = state.parent?.id, - playbackMode = - when (state.parent) { - null -> PlaybackMode.ALL_SONGS - is Album -> PlaybackMode.IN_ALBUM - is Artist -> PlaybackMode.IN_ARTIST - is Genre -> PlaybackMode.IN_GENRE - }) + songUid = state.queue[state.index].uid, + parentUid = state.parent?.uid) writeRawState(rawState) writeQueue(state.queue) @@ -218,11 +202,10 @@ class PlaybackStateDatabase private constructor(context: Context) : val stateData = ContentValues(10).apply { put(StateColumns.COLUMN_ID, 0) - put(StateColumns.COLUMN_SONG_ID, rawState.songId) + put(StateColumns.COLUMN_SONG_UID, rawState.songUid.toString()) put(StateColumns.COLUMN_POSITION, rawState.positionMs) - put(StateColumns.COLUMN_PARENT_ID, rawState.parentId) + put(StateColumns.COLUMN_PARENT_UID, rawState.parentUid?.toString()) put(StateColumns.COLUMN_INDEX, rawState.index) - put(StateColumns.COLUMN_PLAYBACK_MODE, rawState.playbackMode.intCode) put(StateColumns.COLUMN_IS_SHUFFLED, rawState.isShuffled) put(StateColumns.COLUMN_REPEAT_MODE, rawState.repeatMode.intCode) } @@ -255,8 +238,7 @@ class PlaybackStateDatabase private constructor(context: Context) : val itemData = ContentValues(4).apply { put(QueueColumns.ID, idStart + i) - put(QueueColumns.SONG_ID, song.id) - put(QueueColumns.ALBUM_ID, song.album.id) + put(QueueColumns.SONG_UID, song.uid.toString()) } insert(TABLE_NAME_QUEUE, null, itemData) @@ -286,31 +268,28 @@ class PlaybackStateDatabase private constructor(context: Context) : val positionMs: Long, val repeatMode: RepeatMode, val isShuffled: Boolean, - val songId: Long, - val parentId: Long?, - val playbackMode: PlaybackMode + val songUid: Music.UID, + val parentUid: Music.UID? ) private object StateColumns { const val COLUMN_ID = "id" - const val COLUMN_SONG_ID = "song" + const val COLUMN_SONG_UID = "song_uid" const val COLUMN_POSITION = "position" - const val COLUMN_PARENT_ID = "parent" + const val COLUMN_PARENT_UID = "parent" const val COLUMN_INDEX = "queue_index" - const val COLUMN_PLAYBACK_MODE = "playback_mode" const val COLUMN_IS_SHUFFLED = "is_shuffling" const val COLUMN_REPEAT_MODE = "repeat_mode" } private object QueueColumns { const val ID = "id" - const val SONG_ID = "song" - const val ALBUM_ID = "album" + const val SONG_UID = "song_uid" } companion object { const val DB_NAME = "auxio_state_database.db" - const val DB_VERSION = 7 + const val DB_VERSION = 8 const val TABLE_NAME_STATE = "playback_state_table" const val TABLE_NAME_QUEUE = "queue_table" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 65b11c44a..a3e8284b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -449,7 +449,7 @@ class PlaybackStateManager private constructor() { // While we could just save and reload the state, we instead sanitize the state // at runtime for better performance (and to sidestep a co-routine on behalf of the caller). - val oldSongId = song?.id + val oldSongUid = song?.uid val oldPosition = playerState.calculateElapsedPosition() parent = @@ -463,7 +463,7 @@ class PlaybackStateManager private constructor() { _queue = newLibrary.sanitize(_queue).toMutableList() - while (song?.id != oldSongId && index > -1) { + while (song?.uid != oldSongUid && index > -1) { index-- } 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 8159cd4b0..5c9c6252d 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 @@ -196,7 +196,7 @@ class MediaSessionComponent(private val context: Context, private val callback: // instead of loading a bitmap. val description = MediaDescriptionCompat.Builder() - .setMediaId("Song:${song.id}") + .setMediaId(song.uid.toString()) .setTitle(song.resolveName(context)) .setSubtitle(song.resolveIndividualArtistName(context)) .setIconUri(song.album.coverUri) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index c20538dc4..b96b69552 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -356,7 +356,7 @@ class PlaybackService : } is InternalPlayer.Action.Open -> { library.findSongForUri(application, action.uri)?.let { song -> - playbackManager.play(song, settings.libPlaybackMode, settings) + playbackManager.play(song, null, settings) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index a12bd0b8f..f8598370a 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -90,18 +90,18 @@ class SearchAdapter(private val listener: MenuItemListener) : companion object { private val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Item, newItem: Item) = + override fun areContentsTheSame(oldItem: Item, newItem: Item) = when { oldItem is Song && newItem is Song -> - SongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + SongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Album && newItem is Album -> - AlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + AlbumViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Artist && newItem is Artist -> - ArtistViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + ArtistViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> - GenreViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + GenreViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) + HeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) else -> false } } 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 724d17fcf..eea6b3f62 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -170,10 +170,10 @@ class SearchFragment : findNavController() .navigate( when (item) { - is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id) - is Album -> SearchFragmentDirections.actionShowAlbum(item.id) - is Artist -> SearchFragmentDirections.actionShowArtist(item.id) - is Genre -> SearchFragmentDirections.actionShowGenre(item.id) + is Song -> SearchFragmentDirections.actionShowAlbum(item.album.uid) + is Album -> SearchFragmentDirections.actionShowAlbum(item.uid) + is Artist -> SearchFragmentDirections.actionShowArtist(item.uid) + is Genre -> SearchFragmentDirections.actionShowGenre(item.uid) else -> return }) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt index 6e8d0a3fb..21c1b55d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * similarly distorted by the insets, and thus I must go further and modify the edge effect to be at * least somewhat clamped to the insets themselves. * 3. Touch events. Bottom sheets must always intercept touches in their bounds, or they will click - * the now overlapping content view that is only inset by it and not unhidden by it. + * the now overlapping content view that is only inset and not moved out of the way.. * * @author OxygenCobalt */ diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Adapters.kt b/app/src/main/java/org/oxycblt/auxio/ui/recycler/Adapters.kt index 32db9ace4..0e1b0b18f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Adapters.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/recycler/Adapters.kt @@ -40,7 +40,7 @@ abstract class IndicatorAdapter : RecyclerView.Ada holder.updateIndicator( currentItem != null && item.javaClass == currentItem.javaClass && - item.id == currentItem.id, + item == currentItem, isPlaying) } } @@ -57,7 +57,7 @@ abstract class IndicatorAdapter : RecyclerView.Ada if (oldItem != null) { val pos = currentList.indexOfFirst { - it.javaClass == oldItem.javaClass && it.id == oldItem.id + it.javaClass == oldItem.javaClass && item == currentItem } if (pos > -1) { @@ -68,8 +68,7 @@ abstract class IndicatorAdapter : RecyclerView.Ada } if (item != null) { - val pos = - currentList.indexOfFirst { it.javaClass == item.javaClass && it.id == item.id } + val pos = currentList.indexOfFirst { it.javaClass == item.javaClass && it == item } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED) @@ -85,8 +84,7 @@ abstract class IndicatorAdapter : RecyclerView.Ada this.isPlaying = isPlaying if (!updatedItem && item != null) { - val pos = - currentList.indexOfFirst { it.javaClass == item.javaClass && it.id == item.id } + val pos = currentList.indexOfFirst { it.javaClass == item.javaClass && it == item } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt b/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt index d8f5b05ee..7227b7eca 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt @@ -23,23 +23,14 @@ import androidx.recyclerview.widget.AdapterListUpdateCallback import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -/** - * The base for all items in Auxio. Any datatype can derive this type and gain some behavior not - * provided for free by the normal adapter implementations, such as certain types of diffing. - */ -abstract class Item { - /** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */ - abstract val id: Long -} +/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */ +interface Item /** A data object used solely for the "Header" UI element. */ data class Header( /** The string resource used for the header. */ @StringRes val string: Int -) : Item() { - override val id: Long - get() = string.toLong() -} +) : Item /** An interface for detecting if an item has been clicked once. */ interface ItemClickListener { @@ -165,8 +156,5 @@ class SyncListDiffer( * [areContentsTheSame] any object that is derived from [Item]. */ abstract class SimpleItemCallback : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { - if (oldItem.javaClass != newItem.javaClass) return false - return oldItem.id == newItem.id - } + final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem } 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 778d564df..8113da507 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 @@ -62,7 +62,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = + override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.individualArtistRawName == oldItem.individualArtistRawName } @@ -102,7 +102,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Album, newItem: Album) = + override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.artist.rawName == newItem.artist.rawName && oldItem.releaseType == newItem.releaseType @@ -145,7 +145,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Artist, newItem: Artist) = + override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.rawName == newItem.rawName && oldItem.albums.size == newItem.albums.size && oldItem.songs.size == newItem.songs.size @@ -187,7 +187,7 @@ private constructor( val DIFFER = object : SimpleItemCallback() { - override fun areItemsTheSame(oldItem: Genre, newItem: Genre): Boolean = + override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size } } @@ -211,7 +211,7 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin val DIFFER = object : SimpleItemCallback
() { - override fun areItemsTheSame(oldItem: Header, newItem: Header): Boolean = + override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = oldItem.string == newItem.string } } diff --git a/app/src/main/res/navigation/nav_explore.xml b/app/src/main/res/navigation/nav_explore.xml index 524b9c3a0..36381f0a8 100644 --- a/app/src/main/res/navigation/nav_explore.xml +++ b/app/src/main/res/navigation/nav_explore.xml @@ -8,7 +8,7 @@ Now, it would be quite cool if we could implement shared element transitions between elements in this navigation web. Sadly though, the shared element transition system is filled with so many bugs and visual errors to make this a terrible idea. - Just use the boring, yet sane and functional fade transitions. + Just use the boring, yet sane and functional axis transitions. --> + android:name="artistUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> @@ -32,8 +32,8 @@ android:label="AlbumDetailFragment" tools:layout="@layout/fragment_detail"> + android:name="albumUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> @@ -47,8 +47,8 @@ android:label="GenreDetailFragment" tools:layout="@layout/fragment_detail"> + android:name="genreUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index f93b4827f..0e10c762d 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -40,7 +40,7 @@ android:label="song_detail_dialog" tools:layout="@layout/dialog_song_detail"> + android:name="songUid" + app:argType="org.oxycblt.auxio.music.Music$UID" /> \ No newline at end of file