From d5941aa7058511fa8b4b461cc44a62eb5622c72e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 4 Jan 2023 08:15:21 -0700 Subject: [PATCH 01/55] music: fix crash when adding music dirs Fix a crash that would occur when trying to add music dirs without a file manager to handle it. Some users apparently disable the built-in file manager under the assumption that the same intents will work with other file managers. They do not, and so we need to handle that case and let the user know. --- CHANGELOG.md | 6 ++++++ .../org/oxycblt/auxio/detail/DetailViewModel.kt | 4 ++-- .../main/java/org/oxycblt/auxio/music/Date.kt | 3 +-- .../auxio/music/filesystem/MusicDirsDialog.kt | 16 +++++++++++----- .../replaygain/ReplayGainAudioProcessor.kt | 2 +- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d47c38f..f14fdece8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## dev + +#### What's Fixed +- Fixed crash that would occur in music folders dialog when user does not have a working +file manager + ## 3.0.1 #### What's New 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 2f7a404e0..71ec1a6a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -173,8 +173,8 @@ class DetailViewModel(application: Application) : } /** - * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] - * and [songProperties] will be updated to align with the new [Song]. + * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and + * [songProperties] will be updated to align with the new [Song]. * @param uid The UID of the [Song] to load. Must be valid. */ fun setSongUid(uid: Music.UID) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt index f6f7b1221..2e05ef5a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt @@ -140,8 +140,7 @@ class Date private constructor(private val tokens: List) : Comparable min.resolveDate(context) } - override fun equals(other: Any?) = - other is Range && min == other.min && max == other.max + override fun equals(other: Any?) = other is Range && min == other.min && max == other.max override fun hashCode() = 31 * max.hashCode() + min.hashCode() diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt index 441cb7cbe..e294b7915 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio.music.filesystem +import android.content.ActivityNotFoundException import android.net.Uri import android.os.Bundle import android.os.storage.StorageManager @@ -84,10 +85,16 @@ class MusicDirsDialog : val dialog = it as AlertDialog dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { logD("Opening launcher") - requireNotNull(openDocumentTreeLauncher) { - "Document tree launcher was not available" - } - .launch(null) + val launcher = requireNotNull(openDocumentTreeLauncher) { + "Document tree launcher was not available" + } + + try { + launcher.launch(null) + } catch (e: ActivityNotFoundException) { + // User doesn't have a capable file manager. + requireContext().showToast(R.string.err_no_app) + } } } @@ -97,7 +104,6 @@ class MusicDirsDialog : } var dirs = Settings(context).getMusicDirs(storageManager) - if (savedInstanceState != null) { val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) if (pendingDirs != null) { 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 98450a763..7346ac7eb 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 @@ -191,7 +191,7 @@ class ReplayGainAudioProcessor(private val context: Context) : // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the // adjustment by 256 to get the gain. This is used alongside the base adjustment // intrinsic to the format to create the normalized adjustment. That base adjustment - // is already handled by the media framework, so we just need to apply the more + // is already handled by the media framework, so we just need to apply the more // specific adjustments. tags.vorbis[TAG_R128_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } From d55dfbc84965fa65ceed5cf44519ab7573ef774d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 4 Jan 2023 09:40:25 -0700 Subject: [PATCH 02/55] list: make listeners generic Make all list listeners operate on generic values. Wanted to do this for awhile, but couldn't figure out how to get the listener to work with sub-typed listeners until I learned what the in keyword actually does. This removes a ton of type-checking boilerplate that it's not even funny. --- .../auxio/detail/AlbumDetailFragment.kt | 25 +++++------- .../auxio/detail/ArtistDetailFragment.kt | 23 +++++------ .../auxio/detail/GenreDetailFragment.kt | 35 ++++++++-------- .../detail/recycler/AlbumDetailAdapter.kt | 2 +- .../detail/recycler/ArtistDetailAdapter.kt | 4 +- .../auxio/detail/recycler/DetailAdapter.kt | 3 +- .../auxio/home/list/AlbumListFragment.kt | 18 +++------ .../auxio/home/list/ArtistListFragment.kt | 13 +++--- .../auxio/home/list/GenreListFragment.kt | 13 +++--- .../auxio/home/list/SongListFragment.kt | 19 ++++----- .../java/org/oxycblt/auxio/home/tabs/Tab.kt | 3 +- .../org/oxycblt/auxio/home/tabs/TabAdapter.kt | 4 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 7 ++-- .../org/oxycblt/auxio/list/ListFragment.kt | 13 +++--- .../java/org/oxycblt/auxio/list/Listeners.kt | 40 +++++++++---------- .../list/recycler/PlayingIndicatorAdapter.kt | 23 ++++++----- .../auxio/list/recycler/ViewHolders.kt | 13 +++--- .../auxio/music/filesystem/MusicDirsDialog.kt | 7 ++-- .../auxio/music/picker/ArtistChoiceAdapter.kt | 4 +- .../picker/ArtistNavigationPickerDialog.kt | 4 +- .../auxio/music/picker/ArtistPickerDialog.kt | 5 +-- .../picker/ArtistPlaybackPickerDialog.kt | 9 ++--- .../auxio/music/picker/GenreChoiceAdapter.kt | 4 +- .../music/picker/GenrePlaybackPickerDialog.kt | 11 +++-- .../auxio/playback/queue/QueueAdapter.kt | 4 +- .../auxio/playback/queue/QueueFragment.kt | 5 +-- .../replaygain/ReplayGainAudioProcessor.kt | 2 - .../org/oxycblt/auxio/search/SearchAdapter.kt | 7 +--- .../oxycblt/auxio/search/SearchFragment.kt | 19 +++++---- .../oxycblt/auxio/ui/NavigationViewModel.kt | 8 ++-- .../org/oxycblt/auxio/ui/accent/Accent.kt | 3 +- .../oxycblt/auxio/ui/accent/AccentAdapter.kt | 4 +- .../auxio/ui/accent/AccentCustomizeDialog.kt | 6 +-- .../java/org/oxycblt/auxio/util/LangUtil.kt | 11 +++++ 34 files changed, 168 insertions(+), 203 deletions(-) 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 1d3d4f9f3..d94592b23 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -29,7 +29,6 @@ import com.google.android.material.transition.MaterialSharedAxis import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -39,18 +38,14 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.settings.Settings -import org.oxycblt.auxio.util.canScroll -import org.oxycblt.auxio.util.collect -import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast -import org.oxycblt.auxio.util.unlikelyToBeNull +import org.oxycblt.auxio.util.* /** * A [ListFragment] that shows information about an [Album]. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumDetailFragment : ListFragment(), AlbumDetailAdapter.Listener { +class AlbumDetailFragment : + ListFragment(), AlbumDetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. @@ -126,20 +121,20 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd } } - override fun onRealClick(music: Music) { - check(music is Song) { "Unexpected datatype: ${music::class.java}" } + override fun onRealClick(item: Music) { + val song = requireIs(item) when (Settings(requireContext()).detailPlaybackMode) { // "Play from shown item" and "Play from album" functionally have the same // behavior since a song can only have one album. null, - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + MusicMode.ALBUMS -> playbackModel.playFromAlbum(song) + MusicMode.SONGS -> playbackModel.playFromAll(song) + MusicMode.ARTISTS -> playbackModel.playFromArtist(song) + MusicMode.GENRES -> playbackModel.playFromGenre(song) } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" } openMusicMenu(anchor, R.menu.menu_album_song_actions, item) } 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 e1d103861..f87f9ab56 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -29,7 +29,6 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -49,7 +48,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information about an [Artist]. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { +class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an artist. @@ -121,27 +120,27 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte } } - override fun onRealClick(music: Music) { - when (music) { + override fun onRealClick(item: Music) { + when (item) { is Song -> { when (Settings(requireContext()).detailPlaybackMode) { // When configured to play from the selected item, we already have an Artist // to play from. null -> playbackModel.playFromArtist( - music, unlikelyToBeNull(detailModel.currentArtist.value)) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + item, unlikelyToBeNull(detailModel.currentArtist.value)) + MusicMode.SONGS -> playbackModel.playFromAll(item) + MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) + MusicMode.ARTISTS -> playbackModel.playFromArtist(item) + MusicMode.GENRES -> playbackModel.playFromGenre(item) } } - is Album -> navModel.exploreNavigateTo(music) - else -> error("Unexpected datatype: ${music::class.simpleName}") + is Album -> navModel.exploreNavigateTo(item) + else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, 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 bfeca52c3..302d3abfb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -29,7 +29,6 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -50,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information for a particular [Genre]. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { +class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an genre. @@ -120,26 +119,26 @@ class GenreDetailFragment : ListFragment(), DetailAdapter } } - override fun onRealClick(music: Music) { - when (music) { - is Artist -> navModel.exploreNavigateTo(music) + override fun onRealClick(item: Music) { + when (item) { + is Artist -> navModel.exploreNavigateTo(item) is Song -> when (Settings(requireContext()).detailPlaybackMode) { // When configured to play from the selected item, we already have a Genre // to play from. null -> playbackModel.playFromGenre( - music, unlikelyToBeNull(detailModel.currentGenre.value)) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + item, unlikelyToBeNull(detailModel.currentGenre.value)) + MusicMode.SONGS -> playbackModel.playFromAll(item) + MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) + MusicMode.ARTISTS -> playbackModel.playFromArtist(item) + MusicMode.GENRES -> playbackModel.playFromGenre(item) } - else -> error("Unexpected datatype: ${music::class.simpleName}") + else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) @@ -184,17 +183,15 @@ class GenreDetailFragment : ListFragment(), DetailAdapter } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - var item: Item? = null - + var playingMusic: Music? = null if (parent is Artist) { - item = parent + playingMusic = parent } - + // Prefer songs that might be playing from this genre. if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { - item = song + playingMusic = song } - - detailAdapter.setPlayingItem(item, isPlaying) + detailAdapter.setPlayingItem(playingMusic, isPlaying) } private fun handleNavigation(item: Music?) { 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 8cc214b14..fb80c5ba5 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 @@ -226,7 +226,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA * @param song The new [Song] to bind. * @param listener A [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songTrack.apply { 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 84aedabfb..ddcc5ceb7 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 @@ -183,7 +183,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite * @param album The new [Album] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(album: Album, listener: SelectableListListener) { + fun bind(album: Album, listener: SelectableListListener) { listener.bind(album, this, menuButton = binding.parentMenu) binding.parentImage.bind(album) binding.parentName.text = album.resolveName(binding.context) @@ -235,7 +235,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item * @param song The new [Song] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) 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 8775f4b9b..1bc9d25ad 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 @@ -30,6 +30,7 @@ import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.recycler.* +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater @@ -88,7 +89,7 @@ abstract class DetailAdapter( } /** An extended [SelectableListListener] for [DetailAdapter] implementations. */ - interface Listener : SelectableListListener { + interface Listener : SelectableListListener { // TODO: Split off into sub-listeners if a collapsing toolbar is implemented. /** * Called when the play button in a detail header is pressed, requesting that the current 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 ad91fdade..175ac2706 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 @@ -33,11 +33,7 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SyncListDiffer -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately @@ -47,7 +43,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class AlbumListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.Listener, FastScrollRecyclerView.PopupProvider { private val homeModel: HomeViewModel by activityViewModels() @@ -125,13 +121,11 @@ class AlbumListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Album) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Album) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Album) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Album, anchor: View) { openMusicMenu(anchor, R.menu.menu_album_actions, item) } @@ -144,7 +138,7 @@ class AlbumListFragment : * A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class AlbumAdapter(private val listener: SelectableListListener) : + private class AlbumAdapter(private val listener: SelectableListListener) : SelectionIndicatorAdapter() { private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK) 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 2dd51edf6..f87a1a458 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 @@ -32,7 +32,6 @@ import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Sort @@ -45,7 +44,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull * @author Alexander Capehart (OxygenCobalt) */ class ArtistListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() @@ -100,13 +99,11 @@ class ArtistListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Artist) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Artist) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Artist) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Artist, anchor: View) { openMusicMenu(anchor, R.menu.menu_artist_actions, item) } @@ -119,7 +116,7 @@ class ArtistListFragment : * A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class ArtistAdapter(private val listener: SelectableListListener) : + private class ArtistAdapter(private val listener: SelectableListListener) : SelectionIndicatorAdapter() { private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK) diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index fa3484d90..50ed0fc04 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -32,7 +32,6 @@ import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Sort @@ -44,7 +43,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class GenreListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() @@ -99,13 +98,11 @@ class GenreListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Genre) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Genre) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Genre) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Genre, anchor: View) { openMusicMenu(anchor, R.menu.menu_artist_actions, item) } @@ -118,7 +115,7 @@ class GenreListFragment : * A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class GenreAdapter(private val listener: SelectableListListener) : + private class GenreAdapter(private val listener: SelectableListListener) : SelectionIndicatorAdapter() { private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK) 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 0b85bcb5f..8b9db0d83 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 @@ -33,7 +33,6 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SyncListDiffer -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -48,7 +47,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class SongListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() @@ -130,18 +129,16 @@ class SongListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Song) { "Unexpected datatype: ${music::class.java}" } + override fun onRealClick(item: Song) { when (Settings(requireContext()).libPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + MusicMode.SONGS -> playbackModel.playFromAll(item) + MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) + MusicMode.ARTISTS -> playbackModel.playFromArtist(item) + MusicMode.GENRES -> playbackModel.playFromGenre(item) } } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Song) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Song, anchor: View) { openMusicMenu(anchor, R.menu.menu_song_actions, item) } @@ -158,7 +155,7 @@ class SongListFragment : * A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class SongAdapter(private val listener: SelectableListListener) : + private class SongAdapter(private val listener: SelectableListListener) : SelectionIndicatorAdapter() { private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK) diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 1e74ad396..76f7cf95d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -17,7 +17,6 @@ package org.oxycblt.auxio.home.tabs -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.logE @@ -26,7 +25,7 @@ import org.oxycblt.auxio.util.logE * @param mode The type of list in the home view this instance corresponds to. * @author Alexander Capehart (OxygenCobalt) */ -sealed class Tab(open val mode: MusicMode) : Item { +sealed class Tab(open val mode: MusicMode) { /** * A visible tab. This will be visible in the home and tab configuration views. * @param mode The type of list in the home view this instance corresponds to. diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index 756f8fea1..c60084cf9 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. * @param listener A [EditableListListener] for tab interactions. */ -class TabAdapter(private val listener: EditableListListener) : +class TabAdapter(private val listener: EditableListListener) : RecyclerView.Adapter() { /** The current array of [Tab]s. */ var tabs = arrayOf() @@ -93,7 +93,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) : * @param listener A [EditableListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(tab: Tab, listener: EditableListListener) { + fun bind(tab: Tab, listener: EditableListListener) { listener.bind(tab, this, dragHandle = binding.tabDragHandle) binding.tabCheckBox.apply { // Update the CheckBox name to align with the mode diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index 6c9706762..393a4e182 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -26,7 +26,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD @@ -35,7 +34,8 @@ import org.oxycblt.auxio.util.logD * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. * @author Alexander Capehart (OxygenCobalt) */ -class TabCustomizeDialog : ViewBindingDialogFragment(), EditableListListener { +class TabCustomizeDialog : + ViewBindingDialogFragment(), EditableListListener { private val tabAdapter = TabAdapter(this) private var touchHelper: ItemTouchHelper? = null @@ -81,8 +81,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), Edita binding.tabRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Tab) { "Unexpected datatype: ${item::class.java}" } + override fun onClick(item: Tab, viewHolder: RecyclerView.ViewHolder) { // We will need the exact index of the tab to update on in order to // notify the adapter of the change. val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode } diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index 250992d04..dee519263 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -37,7 +37,8 @@ import org.oxycblt.auxio.util.showToast * A Fragment containing a selectable list. * @author Alexander Capehart (OxygenCobalt) */ -abstract class ListFragment : SelectionFragment(), SelectableListListener { +abstract class ListFragment : + SelectionFragment(), SelectableListListener { protected val navModel: NavigationViewModel by activityViewModels() private var currentMenu: PopupMenu? = null @@ -50,12 +51,11 @@ abstract class ListFragment : SelectionFragment(), Selecta /** * Called when [onClick] is called, but does not result in the item being selected. This more or * less corresponds to an [onClick] implementation in a non-[ListFragment]. - * @param music The [Music] item that was clicked. + * @param item The [T] data of the item that was clicked. */ - abstract fun onRealClick(music: Music) + abstract fun onRealClick(item: T) - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) { if (selectionModel.selected.value.isNotEmpty()) { // Map clicking an item to selecting an item when items are already selected. selectionModel.select(item) @@ -65,8 +65,7 @@ abstract class ListFragment : SelectionFragment(), Selecta } } - override fun onSelect(item: Item) { - check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onSelect(item: T) { selectionModel.select(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt index c8f9ebb6d..1afa340c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt @@ -25,26 +25,22 @@ import androidx.recyclerview.widget.RecyclerView * A basic listener for list interactions. * @author Alexander Capehart (OxygenCobalt) */ -interface ClickableListListener { +interface ClickableListListener { /** - * Called when an [Item] in the list is clicked. - * @param item The [Item] that was clicked. + * Called when an item in the list is clicked. + * @param item The [T] item that was clicked. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. */ - fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) + fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. */ - fun bind( - item: Item, - viewHolder: RecyclerView.ViewHolder, - bodyView: View = viewHolder.itemView - ) { + fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) { bodyView.setOnClickListener { onClick(item, viewHolder) } } } @@ -53,7 +49,7 @@ interface ClickableListListener { * An extension of [ClickableListListener] that enables list editing functionality. * @author Alexander Capehart (OxygenCobalt) */ -interface EditableListListener : ClickableListListener { +interface EditableListListener : ClickableListListener { /** * Called when a [RecyclerView.ViewHolder] requests that it should be dragged. * @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged. @@ -62,14 +58,14 @@ interface EditableListListener : ClickableListListener { /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. * @param dragHandle A touchable [View]. Any drag on this view will start a drag event. */ fun bind( - item: Item, + item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView, dragHandle: View @@ -89,30 +85,30 @@ interface EditableListListener : ClickableListListener { * An extension of [ClickableListListener] that enables menu and selection functionality. * @author Alexander Capehart (OxygenCobalt) */ -interface SelectableListListener : ClickableListListener { +interface SelectableListListener : ClickableListListener { /** - * Called when an [Item] in the list requests that a menu related to it should be opened. - * @param item The [Item] to show a menu for. + * Called when an item in the list requests that a menu related to it should be opened. + * @param item The [T] item to open a menu for. * @param anchor The [View] to anchor the menu to. */ - fun onOpenMenu(item: Item, anchor: View) + fun onOpenMenu(item: T, anchor: View) /** - * Called when an [Item] in the list requests that it be selected. - * @param item The [Item] to select. + * Called when an item in the list requests that it be selected. + * @param item The [T] item to select. */ - fun onSelect(item: Item) + fun onSelect(item: T) /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. * @param menuButton A clickable [View]. Any click events on this [View] will open a menu. */ fun bind( - item: Item, + item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView, menuButton: View diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt index ceb1fbf0b..9b07c339f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.list.recycler import android.view.View import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.logD /** @@ -31,7 +32,7 @@ abstract class PlayingIndicatorAdapter : RecyclerV // - The currently playing item, which is usually marked as "selected" and becomes accented. // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is // marked as "playing" or not. - private var currentItem: Item? = null + private var currentMusic: Music? = null private var isPlaying = false /** @@ -44,7 +45,7 @@ abstract class PlayingIndicatorAdapter : RecyclerV override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { // Only try to update the playing indicator if the ViewHolder supports it if (holder is ViewHolder) { - holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) + holder.updatePlayingIndicator(currentList[position] == currentMusic, isPlaying) } if (payloads.isEmpty()) { @@ -55,14 +56,14 @@ abstract class PlayingIndicatorAdapter : RecyclerV } /** * Update the currently playing item in the list. - * @param item The item currently being played, or null if it is not being played. + * @param music The [Music] currently being played, or null if it is not being played. * @param isPlaying Whether playback is ongoing or paused. */ - fun setPlayingItem(item: Item?, isPlaying: Boolean) { + fun setPlayingItem(music: Music?, isPlaying: Boolean) { var updatedItem = false - if (currentItem != item) { - val oldItem = currentItem - currentItem = item + if (currentMusic != music) { + val oldItem = currentMusic + currentMusic = music // Remove the playing indicator from the old item if (oldItem != null) { @@ -75,8 +76,8 @@ abstract class PlayingIndicatorAdapter : RecyclerV } // Enable the playing indicator on the new item - if (item != null) { - val pos = currentList.indexOfFirst { it == item } + if (music != null) { + val pos = currentList.indexOfFirst { it == music } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { @@ -93,8 +94,8 @@ abstract class PlayingIndicatorAdapter : RecyclerV // We may have already called notifyItemChanged before when checking // if the item was being played, so in that case we don't need to // update again here. - if (!updatedItem && item != null) { - val pos = currentList.indexOfFirst { it == item } + if (!updatedItem && music != null) { + val pos = currentList.indexOfFirst { it == music } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 15628c41c..3aaac0609 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -26,10 +26,7 @@ import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.SelectableListListener -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.* import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater @@ -45,7 +42,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : * @param song The new [Song] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) @@ -92,7 +89,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding * @param album The new [Album] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(album: Album, listener: SelectableListListener) { + fun bind(album: Album, listener: SelectableListListener) { listener.bind(album, this, menuButton = binding.parentMenu) binding.parentImage.bind(album) binding.parentName.text = album.resolveName(binding.context) @@ -141,7 +138,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin * @param artist The new [Artist] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(artist: Artist, listener: SelectableListListener) { + fun bind(artist: Artist, listener: SelectableListListener) { listener.bind(artist, this, menuButton = binding.parentMenu) binding.parentImage.bind(artist) binding.parentName.text = artist.resolveName(binding.context) @@ -200,7 +197,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding * @param genre The new [Genre] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(genre: Genre, listener: SelectableListListener) { + fun bind(genre: Genre, listener: SelectableListListener) { listener.bind(genre, this, menuButton = binding.parentMenu) binding.parentImage.bind(genre) binding.parentName.text = genre.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt index e294b7915..4b297b7b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt @@ -85,9 +85,10 @@ class MusicDirsDialog : val dialog = it as AlertDialog dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { logD("Opening launcher") - val launcher = requireNotNull(openDocumentTreeLauncher) { - "Document tree launcher was not available" - } + val launcher = + requireNotNull(openDocumentTreeLauncher) { + "Document tree launcher was not available" + } try { launcher.launch(null) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt index 71bdc09b4..857a55a1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author OxygenCobalt. */ -class ArtistChoiceAdapter(private val listener: ClickableListListener) : +class ArtistChoiceAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { private var artists = listOf() @@ -67,7 +67,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @param artist The new [Artist] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(artist: Artist, listener: ClickableListListener) { + fun bind(artist: Artist, listener: ClickableListListener) { listener.bind(artist, this) binding.pickerImage.bind(artist) binding.pickerName.text = artist.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt index bebfd66da..99be53312 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt @@ -22,7 +22,6 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.NavigationViewModel @@ -41,9 +40,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() { super.onBindingCreated(binding, savedInstanceState) } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { super.onClick(item, viewHolder) - check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } // User made a choice, navigate to it. navModel.exploreNavigateTo(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt index 0bf780537..0e537e3ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt @@ -26,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately @@ -38,7 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ abstract class ArtistPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { protected val pickerModel: PickerViewModel by viewModels() // Okay to leak this since the Listener will not be called until after initialization. private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this) @@ -68,7 +67,7 @@ abstract class ArtistPickerDialog : binding.pickerRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt index 24ed8af43..186404a9d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt @@ -21,11 +21,12 @@ import android.os.Bundle import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.androidActivityViewModels +import org.oxycblt.auxio.util.requireIs +import org.oxycblt.auxio.util.unlikelyToBeNull /** * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous. @@ -42,12 +43,10 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() { super.onBindingCreated(binding, savedInstanceState) } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { super.onClick(item, viewHolder) // User made a choice, play the given song from that artist. - check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } - val song = pickerModel.currentItem.value - check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" } + val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) playbackModel.playFromArtist(song, item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt index 49b5c758a..b2ddef425 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author OxygenCobalt. */ -class GenreChoiceAdapter(private val listener: ClickableListListener) : +class GenreChoiceAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { private var genres = listOf() @@ -67,7 +67,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @param genre The new [Genre] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(genre: Genre, listener: ClickableListListener) { + fun bind(genre: Genre, listener: ClickableListListener) { listener.bind(genre, this) binding.pickerImage.bind(genre) binding.pickerName.text = genre.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt index 0a197ab0e..dc7b3d6af 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt @@ -27,20 +27,21 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.requireIs +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. * @author Alexander Capehart (OxygenCobalt) */ class GenrePlaybackPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { private val pickerModel: PickerViewModel by viewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels() // Information about what Song to show choices for is initially within the navigation arguments @@ -75,11 +76,9 @@ class GenrePlaybackPickerDialog : binding.pickerRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { // User made a choice, play the given song from that genre. - check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" } - val song = pickerModel.currentItem.value - check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" } + val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) playbackModel.playFromGenre(song, item) } } 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 baa4aa76c..cb4ca8a58 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 @@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [EditableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class QueueAdapter(private val listener: EditableListListener) : +class QueueAdapter(private val listener: EditableListListener) : RecyclerView.Adapter() { private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK) // Since PlayingIndicator adapter relies on an item value, we cannot use it for this @@ -178,7 +178,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong * @param listener A [EditableListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(song: Song, listener: EditableListListener) { + fun bind(song: Song, listener: EditableListListener) { listener.bind(song, this, bodyView, binding.songDragHandle) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 826d04270..50326e156 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -27,7 +27,6 @@ import androidx.recyclerview.widget.RecyclerView import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -39,7 +38,7 @@ import org.oxycblt.auxio.util.logD * A [ViewBindingFragment] that displays an editable queue. * @author Alexander Capehart (OxygenCobalt) */ -class QueueFragment : ViewBindingFragment(), EditableListListener { +class QueueFragment : ViewBindingFragment(), EditableListListener { private val queueModel: QueueViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val queueAdapter = QueueAdapter(this) @@ -81,7 +80,7 @@ class QueueFragment : ViewBindingFragment(), EditableListL binding.queueRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { queueModel.goto(viewHolder.bindingAdapterPosition) } 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 7346ac7eb..79d403233 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 @@ -127,11 +127,9 @@ class ReplayGainAudioProcessor(private val context: Context) : // User wants track gain to be preferred. Default to album gain only if // there is no track gain. ReplayGainMode.TRACK -> gain.track == 0f - // User wants album gain to be preferred. Default to track gain only if // here is no album gain. ReplayGainMode.ALBUM -> gain.album != 0f - // User wants album gain to be used when in an album, track gain otherwise. ReplayGainMode.DYNAMIC -> playbackManager.parent is Album && 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 a1260859b..6653b8e24 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -22,17 +22,14 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.recycler.* -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.* /** * An adapter that displays search results. * @param listener An [SelectableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class SearchAdapter(private val listener: SelectableListListener) : +class SearchAdapter(private val listener: SelectableListListener) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { private val differ = AsyncListDiffer(this, DIFF_CALLBACK) 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 f9055e96d..59f622cf5 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -50,7 +50,7 @@ import org.oxycblt.auxio.util.* * * @author Alexander Capehart (OxygenCobalt) */ -class SearchFragment : ListFragment() { +class SearchFragment : ListFragment() { private val searchModel: SearchViewModel by androidViewModels() private val searchAdapter = SearchAdapter(this) private var imm: InputMethodManager? = null @@ -134,26 +134,25 @@ class SearchFragment : ListFragment() { return false } - override fun onRealClick(music: Music) { - when (music) { + override fun onRealClick(item: Music) { + when (item) { is Song -> when (Settings(requireContext()).libPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + MusicMode.SONGS -> playbackModel.playFromAll(item) + MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) + MusicMode.ARTISTS -> playbackModel.playFromArtist(item) + MusicMode.GENRES -> playbackModel.playFromGenre(item) } - is MusicParent -> navModel.exploreNavigateTo(music) + is MusicParent -> navModel.exploreNavigateTo(item) } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) is Genre -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) - else -> logW("Unexpected datatype when opening menu: ${item::class.java}") } } 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 2ea31a0c4..becc077ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -79,15 +79,15 @@ class NavigationViewModel : ViewModel() { /** * Navigate to a given [Music] item. Will do nothing if already navigating. - * @param item The [Music] to navigate to. + * @param music The [Music] to navigate to. */ - fun exploreNavigateTo(item: Music) { + fun exploreNavigateTo(music: Music) { if (_exploreNavigationItem.value != null) { logD("Already navigating, not doing explore action") return } - logD("Navigating to ${item.rawName}") - _exploreNavigationItem.value = item + logD("Navigating to ${music.rawName}") + _exploreNavigationItem.value = music } /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt index 1be63d5df..cfa6525bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.ui.accent import android.os.Build import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.util.logW private val ACCENT_NAMES = @@ -112,7 +111,7 @@ private val ACCENT_PRIMARY_COLORS = * @param index The unique number for this particular accent. * @author Alexander Capehart (OxygenCobalt) */ -class Accent private constructor(val index: Int) : Item { +class Accent private constructor(val index: Int) { /** The name of this [Accent]. */ val name: Int get() = ACCENT_NAMES[index] diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt index a4c1e6015..46e7e66fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class AccentAdapter(private val listener: ClickableListListener) : +class AccentAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { /** The currently selected [Accent]. */ var selectedAccent: Accent? = null @@ -93,7 +93,7 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin * @param accent The new [Accent] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(accent: Accent, listener: ClickableListListener) { + fun bind(accent: Accent, listener: ClickableListListener) { listener.bind(accent, this, binding.accent) binding.accent.apply { // Add a Tooltip based on the content description so that the purpose of this diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index 2cb3c93d4..f4d6237cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -25,7 +25,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD @@ -36,7 +35,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ class AccentCustomizeDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) @@ -80,8 +79,7 @@ class AccentCustomizeDialog : binding.accentRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Accent) { "Unexpected datatype: ${item::class.java}" } + override fun onClick(item: Accent, viewHolder: RecyclerView.ViewHolder) { accentAdapter.setSelectedAccent(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index dd386e770..dfaca9127 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -33,6 +33,17 @@ fun unlikelyToBeNull(value: T?) = value!! } +/** + * Require that the given data is a specific type [T]. + * @param data The data to check. + * @return A data casted to [T]. + * @throws IllegalStateException If the data cannot be casted to [T]. + */ +inline fun requireIs(data: Any): T { + check(data is T) { "Unexpected datatype: ${data::class.simpleName}" } + return data +} + /** * Aliases a check to ensure that the given number is non-zero. * @return The given number if it's non-zero, null otherwise. From 1b44eeae15539b086ab520e447c3f42590e0a1f5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 4 Jan 2023 09:45:24 -0700 Subject: [PATCH 03/55] music: rename filesystem back into storage Rename the filesystem module into storage. Forgot about the MediaStore utils I had in there. The original name was more correct. --- .../main/java/org/oxycblt/auxio/detail/Detail.kt | 2 +- .../org/oxycblt/auxio/detail/DetailViewModel.kt | 2 +- app/src/main/java/org/oxycblt/auxio/music/Music.kt | 2 +- .../java/org/oxycblt/auxio/music/MusicStore.kt | 4 ++-- .../auxio/music/extractor/MediaStoreExtractor.kt | 14 +++++++------- .../auxio/music/extractor/MetadataExtractor.kt | 2 +- .../{filesystem => storage}/DirectoryAdapter.kt | 2 +- .../music/{filesystem => storage}/Filesystem.kt | 2 +- .../{filesystem => storage}/MusicDirsDialog.kt | 2 +- .../FilesystemUtil.kt => storage/StorageUtil.kt} | 2 +- .../oxycblt/auxio/music/system/IndexerService.kt | 2 +- .../java/org/oxycblt/auxio/settings/Settings.kt | 4 ++-- app/src/main/res/navigation/nav_main.xml | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{filesystem => storage}/DirectoryAdapter.kt (98%) rename app/src/main/java/org/oxycblt/auxio/music/{filesystem => storage}/Filesystem.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{filesystem => storage}/MusicDirsDialog.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{filesystem/FilesystemUtil.kt => storage/StorageUtil.kt} (99%) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt index d5b32e584..c1a586010 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail import androidx.annotation.StringRes import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.MimeType +import org.oxycblt.auxio.music.storage.MimeType /** * A header variation that displays a button to open a sort menu. 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 71ec1a6a8..0ef6b3054 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.music.filesystem.MimeType +import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* 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 095bae072..cc0f9bf03 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -30,7 +30,7 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.filesystem.* +import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.settings.Settings 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 aa40f8ec5..f59356a3d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -20,8 +20,8 @@ package org.oxycblt.auxio.music import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import org.oxycblt.auxio.music.filesystem.contentResolverSafe -import org.oxycblt.auxio.music.filesystem.useQuery +import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.storage.useQuery /** * A repository granting access to the music library.. diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index cc65b8b7f..88bce111f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -29,13 +29,13 @@ import androidx.core.database.getStringOrNull import java.io.File import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.Directory -import org.oxycblt.auxio.music.filesystem.contentResolverSafe -import org.oxycblt.auxio.music.filesystem.directoryCompat -import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat -import org.oxycblt.auxio.music.filesystem.safeQuery -import org.oxycblt.auxio.music.filesystem.storageVolumesCompat -import org.oxycblt.auxio.music.filesystem.useQuery +import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.storage.directoryCompat +import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat +import org.oxycblt.auxio.music.storage.safeQuery +import org.oxycblt.auxio.music.storage.storageVolumesCompat +import org.oxycblt.auxio.music.storage.useQuery import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat 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 8aad9fab7..74efcb29a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -23,7 +23,7 @@ import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.toAudioUri +import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt index 5531d08ca..dcdedc0ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt index b48e19c11..00a22deaf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.content.Context import android.media.MediaFormat diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index 4b297b7b0..b16db8f79 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.content.ActivityNotFoundException import android.net.Uri diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt index 8302aaf24..60bc797e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.annotation.SuppressLint import android.content.ContentResolver diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 2f89bbe3b..9696b44e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.filesystem.contentResolverSafe +import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.settings.Settings 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 2412b5ee9..aabb07823 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -31,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.music.filesystem.Directory -import org.oxycblt.auxio.music.filesystem.MusicDirectories +import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.storage.MusicDirectories import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index ab64b289e..d5c6c0b8d 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -106,7 +106,7 @@ tools:layout="@layout/dialog_pre_amp" /> Date: Wed, 4 Jan 2023 19:08:45 -0700 Subject: [PATCH 04/55] playback: implement new queue system Implement a new heap-based queue system into the app. Unlike the prior queue, this one no longer keeps one canonical queue and then simply swaps between shuffled and ordered queues by re-grabbing the song list currently being played. Instead, the same "heap" of songs is used throughout, with only the way they are interpreted changing. This enables a bunch of near functionality that would not be possible with the prior queue system, but is also really unstable and needs a lot more testing. Currently this commit disables state saving at the moment. It will be re-enabled when the new queue can be reliably restored. --- .../java/org/oxycblt/auxio/music/Music.kt | 2 +- .../music/extractor/MediaStoreExtractor.kt | 2 +- .../music/extractor/MetadataExtractor.kt | 2 +- .../auxio/playback/PlaybackViewModel.kt | 84 ++-- .../auxio/playback/queue/QueueViewModel.kt | 41 +- .../replaygain/ReplayGainAudioProcessor.kt | 2 +- .../playback/state/PlaybackStateManager.kt | 402 +++++++----------- .../org/oxycblt/auxio/playback/state/Queue.kt | 235 ++++++++++ .../playback/system/MediaButtonReceiver.kt | 2 +- .../playback/system/MediaSessionComponent.kt | 52 +-- .../auxio/playback/system/PlaybackService.kt | 15 +- .../oxycblt/auxio/widgets/WidgetComponent.kt | 11 +- 12 files changed, 501 insertions(+), 349 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt 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 cc0f9bf03..8d76972c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -30,9 +30,9 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue +import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 88bce111f..1ac7b082a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -29,6 +29,7 @@ import androidx.core.database.getStringOrNull import java.io.File import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.directoryCompat @@ -36,7 +37,6 @@ import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.storage.safeQuery import org.oxycblt.auxio.music.storage.storageVolumesCompat import org.oxycblt.auxio.music.storage.useQuery -import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD 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 74efcb29a..d2047fabe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -23,8 +23,8 @@ import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.music.parsing.parseId3v2Position +import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW 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 2f5ea9f14..0ae5f2765 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -26,10 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.playback.state.InternalPlayer -import org.oxycblt.auxio.playback.state.PlaybackStateDatabase -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.context @@ -100,13 +97,18 @@ class PlaybackViewModel(application: Application) : playbackManager.removeListener(this) } - override fun onIndexMoved(index: Int) { - _song.value = playbackManager.song + override fun onIndexMoved(queue: Queue) { + _song.value = playbackManager.queue.currentSong } - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { - _song.value = playbackManager.song + override fun onQueueReworked(queue: Queue) { + _isShuffled.value = queue.isShuffled + } + + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + _song.value = playbackManager.queue.currentSong _parent.value = playbackManager.parent + _isShuffled.value = queue.isShuffled } override fun onStateChanged(state: InternalPlayer.State) { @@ -126,10 +128,6 @@ class PlaybackViewModel(application: Application) : } } - override fun onShuffledChanged(isShuffled: Boolean) { - _isShuffled.value = isShuffled - } - override fun onRepeatChanged(repeatMode: RepeatMode) { _repeatMode.value = repeatMode } @@ -141,21 +139,19 @@ class PlaybackViewModel(application: Application) : * @param song The [Song] to play. */ fun playFromAll(song: Song) { - playbackManager.play(song, null, settings) + playImpl(song, null) } /** Shuffle all songs in the music library. */ fun shuffleAll() { - playbackManager.play(null, null, settings, true) + playImpl(null, null, true) } /** * Play a [Song] from it's [Album]. * @param song The [Song] to play. */ - fun playFromAlbum(song: Song) { - playbackManager.play(song, song.album, settings) - } + fun playFromAlbum(song: Song) = playImpl(song, song.album) /** * Play a [Song] from one of it's [Artist]s. @@ -165,10 +161,9 @@ class PlaybackViewModel(application: Application) : */ fun playFromArtist(song: Song, artist: Artist? = null) { if (artist != null) { - check(artist in song.artists) { "Artist not in song artists" } - playbackManager.play(song, artist, settings) + playImpl(song, artist) } else if (song.artists.size == 1) { - playbackManager.play(song, song.artists[0], settings) + playImpl(song, song.artists[0]) } else { _artistPlaybackPickerSong.value = song } @@ -191,10 +186,9 @@ class PlaybackViewModel(application: Application) : */ fun playFromGenre(song: Song, genre: Genre? = null) { if (genre != null) { - check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" } - playbackManager.play(song, genre, settings) + playImpl(song, genre) } else if (song.genres.size == 1) { - playbackManager.play(song, song.genres[0], settings) + playImpl(song, song.genres[0]) } else { _genrePlaybackPickerSong.value = song } @@ -204,49 +198,37 @@ class PlaybackViewModel(application: Application) : * Play an [Album]. * @param album The [Album] to play. */ - fun play(album: Album) { - playbackManager.play(null, album, settings, false) - } + fun play(album: Album) = playImpl(null, album, false) /** * Play an [Artist]. * @param artist The [Artist] to play. */ - fun play(artist: Artist) { - playbackManager.play(null, artist, settings, false) - } + fun play(artist: Artist) = playImpl(null, artist, false) /** * Play a [Genre]. * @param genre The [Genre] to play. */ - fun play(genre: Genre) { - playbackManager.play(null, genre, settings, false) - } + fun play(genre: Genre) = playImpl(null, genre, false) /** * Shuffle an [Album]. * @param album The [Album] to shuffle. */ - fun shuffle(album: Album) { - playbackManager.play(null, album, settings, true) - } + fun shuffle(album: Album) = playImpl(null, album, true) /** * Shuffle an [Artist]. * @param artist The [Artist] to shuffle. */ - fun shuffle(artist: Artist) { - playbackManager.play(null, artist, settings, true) - } + fun shuffle(artist: Artist) = playImpl(null, artist, true) /** * Shuffle an [Genre]. * @param genre The [Genre] to shuffle. */ - fun shuffle(genre: Genre) { - playbackManager.play(null, genre, settings, true) - } + fun shuffle(genre: Genre) = playImpl(null, genre, true) /** * Start the given [InternalPlayer.Action] to be completed eventually. This can be used to @@ -257,6 +239,24 @@ class PlaybackViewModel(application: Application) : playbackManager.startAction(action) } + private fun playImpl( + song: Song?, + parent: MusicParent?, + shuffled: Boolean = playbackManager.queue.isShuffled && settings.keepShuffle + ) { + check(song == null || parent == null || parent.songs.contains(song)) { + "Song to play not in parent" + } + val sort = + when (parent) { + is Genre -> settings.detailGenreSort + is Artist -> settings.detailArtistSort + is Album -> settings.detailAlbumSort + null -> settings.libSongSort + } + playbackManager.play(song, parent, sort, shuffled) + } + // --- PLAYER FUNCTIONS --- /** @@ -370,7 +370,7 @@ class PlaybackViewModel(application: Application) : /** Toggle [isShuffled] (ex. from on to off) */ fun invertShuffled() { - playbackManager.reshuffle(!playbackManager.isShuffled, settings) + playbackManager.reorder(!playbackManager.queue.isShuffled) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index fb30d6c5c..44c227759 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. @@ -36,7 +37,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { /** The current queue. */ val queue: StateFlow> = _queue - private val _index = MutableStateFlow(playbackManager.index) + private val _index = MutableStateFlow(playbackManager.queue.index) /** The index of the currently playing song in the queue. */ val index: StateFlow get() = _index @@ -56,10 +57,6 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * range. */ fun goto(adapterIndex: Int) { - if (adapterIndex !in playbackManager.queue.indices) { - // Invalid input. Nothing to do. - return - } playbackManager.goto(adapterIndex) } @@ -69,10 +66,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * range. */ fun removeQueueDataItem(adapterIndex: Int) { - if (adapterIndex <= playbackManager.index || - adapterIndex !in playbackManager.queue.indices) { - // Invalid input. Nothing to do. - // TODO: Allow editing played queue items. + if (adapterIndex !in queue.value.indices) { return } playbackManager.removeQueueItem(adapterIndex) @@ -85,7 +79,8 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * @return true if the items were moved, false otherwise. */ fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean { - if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) { + if (adapterFrom <= playbackManager.queue.index || + adapterTo <= playbackManager.queue.index) { // Invalid input. Nothing to do. return false } @@ -103,34 +98,34 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { scrollTo = null } - override fun onIndexMoved(index: Int) { + override fun onIndexMoved(queue: Queue) { // Index moved -> Scroll to new index replaceQueue = null - scrollTo = index - _index.value = index + scrollTo = queue.index + _index.value = queue.index } - override fun onQueueChanged(queue: List) { + override fun onQueueChanged(queue: Queue) { // Queue changed trivially due to item move -> Diff queue, stay at current index. replaceQueue = false scrollTo = null - _queue.value = playbackManager.queue.toMutableList() + _queue.value = queue.resolve() } - override fun onQueueReworked(index: Int, queue: List) { + override fun onQueueReworked(queue: Queue) { // Queue changed completely -> Replace queue, update index replaceQueue = true - scrollTo = index - _queue.value = playbackManager.queue.toMutableList() - _index.value = index + scrollTo = queue.index + _queue.value = queue.resolve() + _index.value = queue.index } - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { // Entirely new queue -> Replace queue, update index replaceQueue = true - scrollTo = index - _queue.value = playbackManager.queue.toMutableList() - _index.value = index + scrollTo = queue.index + _queue.value = queue.resolve() + _index.value = queue.index } override fun onCleared() { 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 79d403233..77a633140 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 @@ -133,7 +133,7 @@ class ReplayGainAudioProcessor(private val context: Context) : // User wants album gain to be used when in an album, track gain otherwise. ReplayGainMode.DYNAMIC -> playbackManager.parent is Album && - playbackManager.song?.album == playbackManager.parent + playbackManager.queue.currentSong?.album == playbackManager.parent } val resolvedGain = 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 fb2995839..11f710456 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 @@ -17,18 +17,11 @@ package org.oxycblt.auxio.playback.state -import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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.MusicParent -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -59,22 +52,13 @@ class PlaybackStateManager private constructor() { @Volatile private var pendingAction: InternalPlayer.Action? = null @Volatile private var isInitialized = false - /** The currently playing [Song]. Null if nothing is playing. */ - val song - get() = queue.getOrNull(index) + /** The current [Queue]. */ + val queue = Queue() /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ @Volatile var parent: MusicParent? = null private set - @Volatile private var _queue = mutableListOf() - /** The current queue. */ - val queue - get() = _queue - /** The position of the currently playing item in the queue. */ - @Volatile - var index = -1 - private set /** The current [InternalPlayer] state. */ @Volatile var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) @@ -86,13 +70,8 @@ class PlaybackStateManager private constructor() { field = value notifyRepeatModeChanged() } - /** Whether the queue is shuffled. */ - @Volatile - var isShuffled = false - private set /** - * The current audio session ID of the internal player. Null if no [InternalPlayer] is - * available. + * The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable. */ val currentAudioSessionId: Int? get() = internalPlayer?.audioSessionId @@ -106,9 +85,8 @@ class PlaybackStateManager private constructor() { @Synchronized fun addListener(listener: Listener) { if (isInitialized) { - listener.onNewPlayback(index, queue, parent) + listener.onNewPlayback(queue, parent) listener.onRepeatChanged(repeatMode) - listener.onShuffledChanged(isShuffled) listener.onStateChanged(playerState) } @@ -141,7 +119,7 @@ class PlaybackStateManager private constructor() { } if (isInitialized) { - internalPlayer.loadSong(song, playerState.isPlaying) + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) internalPlayer.seekTo(playerState.calculateElapsedPositionMs()) // See if there's any action that has been queued. requestAction(internalPlayer) @@ -175,27 +153,19 @@ class PlaybackStateManager private constructor() { * @param song A particular [Song] to play, or null to play the first [Song] in the new queue. * @param parent The [MusicParent] to play from, or null if to play from the entire * [MusicStore.Library]. - * @param settings [Settings] required to configure the queue. - * @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle" - * configuration. + * @param sort [Sort] to initially sort an ordered queue with. + * @param shuffled Whether to shuffle or not. */ @Synchronized - fun play( - song: Song?, - parent: MusicParent?, - settings: Settings, - shuffled: Boolean = settings.keepShuffle && isShuffled - ) { + fun play(song: Song?, parent: MusicParent?, sort: Sort, shuffled: Boolean) { val internalPlayer = internalPlayer ?: return val library = musicStore.library ?: return - // Setup parent and queue + // Set up parent and queue this.parent = parent - _queue = (parent?.songs ?: library.songs).toMutableList() - orderQueue(settings, shuffled, song) + queue.start(song, sort.songs(parent?.songs ?: library.songs), shuffled) // Notify components of changes notifyNewPlayback() - notifyShuffledChanged() - internalPlayer.loadSong(this.song, true) + internalPlayer.loadSong(queue.currentSong, true) // Played something, so we are initialized now isInitialized = true } @@ -209,13 +179,13 @@ class PlaybackStateManager private constructor() { @Synchronized fun next() { val internalPlayer = internalPlayer ?: return - // Increment the index, if it cannot be incremented any further, then - // repeat and pause/resume playback depending on the setting - if (index < _queue.lastIndex) { - gotoImpl(internalPlayer, index + 1, true) - } else { - gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL) + var play = true + if (!queue.goto(queue.index + 1)) { + queue.goto(0) + play = false } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, play) } /** @@ -231,7 +201,11 @@ class PlaybackStateManager private constructor() { rewind() setPlaying(true) } else { - gotoImpl(internalPlayer, max(index - 1, 0), true) + if (!queue.goto(queue.index - 1)) { + queue.goto(0) + } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) } } @@ -242,13 +216,10 @@ class PlaybackStateManager private constructor() { @Synchronized fun goto(index: Int) { val internalPlayer = internalPlayer ?: return - gotoImpl(internalPlayer, index, true) - } - - private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) { - index = idx - notifyIndexMoved() - internalPlayer.loadSong(song, play) + if (queue.goto(index)) { + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } } /** @@ -257,7 +228,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun playNext(song: Song) { - _queue.add(index + 1, song) + queue.playNext(listOf(song)) notifyQueueChanged() } @@ -267,7 +238,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun playNext(songs: List) { - _queue.addAll(index + 1, songs) + queue.playNext(songs) notifyQueueChanged() } @@ -277,7 +248,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun addToQueue(song: Song) { - _queue.add(song) + queue.addToQueue(listOf(song)) notifyQueueChanged() } @@ -287,82 +258,41 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun addToQueue(songs: List) { - _queue.addAll(songs) + queue.addToQueue(songs) notifyQueueChanged() } /** * Move a [Song] in the queue. - * @param from The position of the [Song] to move in the queue. - * @param to The destination position in the queue. + * @param src The position of the [Song] to move in the queue. + * @param dst The destination position in the queue. */ @Synchronized - fun moveQueueItem(from: Int, to: Int) { - logD("Moving item $from to position $to") - _queue.add(to, _queue.removeAt(from)) + fun moveQueueItem(src: Int, dst: Int) { + logD("Moving item $src to position $dst") + queue.move(src, dst) notifyQueueChanged() } /** * Remove a [Song] from the queue. - * @param index The position of the [Song] to remove in the queue. + * @param at The position of the [Song] to remove in the queue. */ @Synchronized - fun removeQueueItem(index: Int) { - logD("Removing item ${_queue[index].rawName}") - _queue.removeAt(index) + fun removeQueueItem(at: Int) { + logD("Removing item at $at") + queue.remove(at) notifyQueueChanged() } /** * (Re)shuffle or (Re)order this instance. * @param shuffled Whether to shuffle the queue or not. - * @param settings [Settings] required to configure the queue. */ @Synchronized - fun reshuffle(shuffled: Boolean, settings: Settings) { - val song = song ?: return - orderQueue(settings, shuffled, song) + fun reorder(shuffled: Boolean) { + queue.reorder(shuffled) notifyQueueReworked() - notifyShuffledChanged() - } - - /** - * Re-configure the queue. - * @param settings [Settings] required to configure the queue. - * @param shuffled Whether to shuffle the queue or not. - * @param keep the [Song] to start at in the new queue, or null if not specified. - */ - private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) { - val newIndex: Int - if (shuffled) { - // Shuffling queue, randomize the current song list and move the Song to play - // to the start. - _queue.shuffle() - if (keep != null) { - _queue.add(0, _queue.removeAt(_queue.indexOf(keep))) - } - newIndex = 0 - } else { - // Ordering queue, re-sort it using the analogous parent sort configuration and - // then jump to the Song to play. - // TODO: Rework queue system to avoid having to do this - val sort = - parent.let { parent -> - when (parent) { - null -> settings.libSongSort - is Album -> settings.detailAlbumSort - is Artist -> settings.detailArtistSort - is Genre -> settings.detailGenreSort - } - } - sort.songsInPlace(_queue) - newIndex = keep?.let(_queue::indexOf) ?: 0 - } - - _queue = queue - index = newIndex - isShuffled = shuffled } // --- INTERNAL PLAYER FUNCTIONS --- @@ -379,7 +309,7 @@ class PlaybackStateManager private constructor() { return } - val newState = internalPlayer.getState(song?.durationMs ?: 0) + val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0) if (newState != playerState) { playerState = newState notifyStateChanged() @@ -452,44 +382,49 @@ class PlaybackStateManager private constructor() { return false } - val library = musicStore.library ?: return false - val internalPlayer = internalPlayer ?: return false - val state = - try { - withContext(Dispatchers.IO) { database.read(library) } - } catch (e: Exception) { - logE("Unable to restore playback state.") - logE(e.stackTraceToString()) - return false - } + // TODO: Re-implement with new queue + return false - // Translate the state we have just read into a usable playback state for this - // instance. - return synchronized(this) { - // State could have changed while we were loading, so check if we were initialized - // now before applying the state. - if (state != null && (!isInitialized || force)) { - index = state.index - parent = state.parent - _queue = state.queue.toMutableList() - repeatMode = state.repeatMode - isShuffled = state.isShuffled - - notifyNewPlayback() - notifyRepeatModeChanged() - notifyShuffledChanged() - - // Continuing playback after drastic state updates is a bad idea, so pause. - internalPlayer.loadSong(song, false) - internalPlayer.seekTo(state.positionMs) - - isInitialized = true - - true - } else { - false - } - } + // val library = musicStore.library ?: return false + // val internalPlayer = internalPlayer ?: return false + // val state = + // try { + // withContext(Dispatchers.IO) { database.read(library) } + // } catch (e: Exception) { + // logE("Unable to restore playback state.") + // logE(e.stackTraceToString()) + // return false + // } + // + // // Translate the state we have just read into a usable playback state for this + // // instance. + // return synchronized(this) { + // // State could have changed while we were loading, so check if we were + // initialized + // // now before applying the state. + // if (state != null && (!isInitialized || force)) { + // index = state.index + // parent = state.parent + // _queue = state.queue.toMutableList() + // repeatMode = state.repeatMode + // isShuffled = state.isShuffled + // + // notifyNewPlayback() + // notifyRepeatModeChanged() + // notifyShuffledChanged() + // + // // Continuing playback after drastic state updates is a bad idea, so + // pause. + // internalPlayer.loadSong(song, false) + // internalPlayer.seekTo(state.positionMs) + // + // isInitialized = true + // + // true + // } else { + // false + // } + // } } /** @@ -499,26 +434,26 @@ class PlaybackStateManager private constructor() { */ suspend fun saveState(database: PlaybackStateDatabase): Boolean { logD("Saving state to DB") - - // Create the saved state from the current playback state. - val state = - synchronized(this) { - PlaybackStateDatabase.SavedState( - index = index, - parent = parent, - queue = _queue, - positionMs = playerState.calculateElapsedPositionMs(), - isShuffled = isShuffled, - repeatMode = repeatMode) - } - return try { - withContext(Dispatchers.IO) { database.write(state) } - true - } catch (e: Exception) { - logE("Unable to save playback state.") - logE(e.stackTraceToString()) - false - } + return false + // // Create the saved state from the current playback state. + // val state = + // synchronized(this) { + // PlaybackStateDatabase.SavedState( + // index = index, + // parent = parent, + // queue = _queue, + // positionMs = playerState.calculateElapsedPositionMs(), + // isShuffled = isShuffled, + // repeatMode = repeatMode) + // } + // return try { + // withContext(Dispatchers.IO) { database.write(state) } + // true + // } catch (e: Exception) { + // logE("Unable to save playback state.") + // logE(e.stackTraceToString()) + // false + // } } /** @@ -543,54 +478,57 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun sanitize(newLibrary: MusicStore.Library) { - if (!isInitialized) { - // Nothing playing, nothing to do. - logD("Not initialized, no need to sanitize") - return - } - - val internalPlayer = internalPlayer ?: return - - logD("Sanitizing state") - - // 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). - - // Sanitize parent - parent = - parent?.let { - when (it) { - is Album -> newLibrary.sanitize(it) - is Artist -> newLibrary.sanitize(it) - is Genre -> newLibrary.sanitize(it) - } - } - - // Sanitize queue. Make sure we re-align the index to point to the previously playing - // Song in the queue queue. - val oldSongUid = song?.uid - _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) } - while (song?.uid != oldSongUid && index > -1) { - index-- - } - - notifyNewPlayback() - - val oldPosition = playerState.calculateElapsedPositionMs() - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(song, false) - if (index > -1) { - // Internal player may have reloaded the media item, re-seek to the previous position - seekTo(oldPosition) - } + // if (!isInitialized) { + // // Nothing playing, nothing to do. + // logD("Not initialized, no need to sanitize") + // return + // } + // + // val internalPlayer = internalPlayer ?: return + // + // logD("Sanitizing state") + // + // // 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). + // + // // Sanitize parent + // parent = + // parent?.let { + // when (it) { + // is Album -> newLibrary.sanitize(it) + // is Artist -> newLibrary.sanitize(it) + // is Genre -> newLibrary.sanitize(it) + // } + // } + // + // // Sanitize queue. Make sure we re-align the index to point to the previously + // playing + // // Song in the queue queue. + // val oldSongUid = song?.uid + // _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) } + // while (song?.uid != oldSongUid && index > -1) { + // index-- + // } + // + // notifyNewPlayback() + // + // val oldPosition = playerState.calculateElapsedPositionMs() + // // Continuing playback while also possibly doing drastic state updates is + // // a bad idea, so pause. + // internalPlayer.loadSong(song, false) + // if (index > -1) { + // // Internal player may have reloaded the media item, re-seek to the previous + // position + // seekTo(oldPosition) + // } } // --- CALLBACKS --- private fun notifyIndexMoved() { for (callback in listeners) { - callback.onIndexMoved(index) + callback.onIndexMoved(queue) } } @@ -602,13 +540,13 @@ class PlaybackStateManager private constructor() { private fun notifyQueueReworked() { for (callback in listeners) { - callback.onQueueReworked(index, queue) + callback.onQueueReworked(queue) } } private fun notifyNewPlayback() { for (callback in listeners) { - callback.onNewPlayback(index, queue, parent) + callback.onNewPlayback(queue, parent) } } @@ -624,12 +562,6 @@ class PlaybackStateManager private constructor() { } } - private fun notifyShuffledChanged() { - for (callback in listeners) { - callback.onShuffledChanged(isShuffled) - } - } - /** * The interface for receiving updates from [PlaybackStateManager]. Add the listener to * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener]. @@ -638,30 +570,29 @@ class PlaybackStateManager private constructor() { /** * Called when the position of the currently playing item has changed, changing the current * [Song], but no other queue attribute has changed. - * @param index The new position in the queue. + * @param queue The new [Queue]. */ - fun onIndexMoved(index: Int) {} + fun onIndexMoved(queue: Queue) {} /** - * Called when the queue changed in a trivial manner, such as a move. - * @param queue The new queue. + * Called when the [Queue] changed in a trivial manner, such as a move. + * @param queue The new [Queue]. */ - fun onQueueChanged(queue: List) {} + fun onQueueChanged(queue: Queue) {} /** - * Called when the queue has changed in a non-trivial manner (such as re-shuffling), but the - * currently playing [Song] has not. - * @param index The new position in the queue. + * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but + * the currently playing [Song] has not. + * @param queue The new [Queue]. */ - fun onQueueReworked(index: Int, queue: List) {} + fun onQueueReworked(queue: Queue) {} /** * Called when a new playback configuration was created. - * @param index The new position in the queue. - * @param queue The new queue. + * @param queue The new [Queue]. * @param parent The new [MusicParent] being played from, or null if playing from all songs. */ - fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) {} + fun onNewPlayback(queue: Queue, parent: MusicParent?) {} /** * Called when the state of the [InternalPlayer] changes. @@ -674,13 +605,6 @@ class PlaybackStateManager private constructor() { * @param repeatMode The new [RepeatMode]. */ fun onRepeatChanged(repeatMode: RepeatMode) {} - - /** - * Called when the queue's shuffle state changes. Handling the queue change itself should - * occur in [onQueueReworked], - * @param isShuffled Whether the queue is shuffled. - */ - fun onShuffledChanged(isShuffled: Boolean) {} } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt new file mode 100644 index 000000000..8cb285d69 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.state + +import kotlin.random.Random +import kotlin.random.nextInt +import org.oxycblt.auxio.music.Song + +/** + * A heap-backed play queue. + * + * Whereas other queue implementations use a plain list, Auxio requires a more complicated data + * structure in order to implement features such as gapless playback in ExoPlayer. This queue + * implementation is instead based around an unorganized "heap" of [Song] instances, that are then + * interpreted into different queues depending on the current playback configuration. + * + * In general, the implementation details don't need ot be known for this data structure to be used. + * The functions exposed should be familiar for any typical play queue. + * + * @author OxygenCobalt + */ +class Queue { + @Volatile private var heap = mutableListOf() + @Volatile private var orderedMapping = mutableListOf() + @Volatile private var shuffledMapping = mutableListOf() + /** The index of the currently playing [Song] in the current mapping. */ + @Volatile + var index = 0 + private set + /** The currently playing [Song]. */ + val currentSong: Song? + get() = shuffledMapping.ifEmpty { orderedMapping.ifEmpty { null } }?.let { heap[it[index]] } + /** Whether this queue is shuffled. */ + val isShuffled: Boolean + get() = shuffledMapping.isNotEmpty() + + /** + * Resolve this queue into a more conventional list of [Song]s. + * @return A list of [Song] corresponding to the current queue mapping. + */ + fun resolve() = shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } + + /** + * Go to a particular index in the queue. + * @param to The index of the [Song] to start playing, in the current queue mapping. + * @return true if the queue jumped to that position, false otherwise. + */ + fun goto(to: Int): Boolean { + if (to !in orderedMapping.indices) { + return false + } + index = to + return true + } + + /** + * Start a new queue configuration. + * @param song The [Song] to play, or null to start from a random position. + * @param queue The queue of [Song]s to play. Must contain [song]. This list will become the + * heap internally. + * @param shuffled Whether to shuffle the queue or not. This changes the interpretation of + * [queue]. + */ + fun start(song: Song?, queue: List, shuffled: Boolean) { + heap = queue.toMutableList() + orderedMapping = MutableList(queue.size) { it } + shuffledMapping = mutableListOf() + index = + song?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0 + reorder(shuffled) + } + + /** + * Re-order the queue. + * @param shuffled Whether the queue should be shuffled or not. + */ + fun reorder(shuffled: Boolean) { + if (shuffled) { + val trueIndex = + if (shuffledMapping.isNotEmpty()) { + // Re-shuffling, song to preserve is in the shuffled mapping + shuffledMapping[index] + } else { + // First shuffle, song to preserve is in the ordered mapping + orderedMapping[index] + } + + // Since we are re-shuffling existing songs, we use the previous mapping size + // instead of the total queue size. + shuffledMapping = MutableList(orderedMapping.size) { it }.apply { shuffle() } + shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex))) + index = 0 + } else if (shuffledMapping.isNotEmpty()) { + // Un-shuffling, song to preserve is in the shuffled mapping. + index = orderedMapping.indexOf(shuffledMapping[index]) + shuffledMapping = mutableListOf() + } + } + + /** + * Add [Song]s to the top of the queue. Will start playback if nothing is playing. + * @param songs The [Song]s to add. + */ + fun playNext(songs: List) { + if (orderedMapping.isEmpty()) { + // No playback, start playing these songs. + start(songs[0], songs, false) + return + } + + val heapIndices = songs.map(::addSongToHeap) + if (shuffledMapping.isNotEmpty()) { + // Add the new songs in front of the current index in the shuffled mapping and in front + // of the analogous list song in the ordered mapping. + val orderedIndex = orderedMapping.indexOf(shuffledMapping[index]) + orderedMapping.addAll(orderedIndex, heapIndices) + shuffledMapping.addAll(index, heapIndices) + } else { + // Add the new song in front of the current index in the ordered mapping. + orderedMapping.addAll(index, heapIndices) + } + } + + /** + * Add [Song]s to the end of the queue. Will start playback if nothing is playing. + * @param songs The [Song]s to add. + */ + fun addToQueue(songs: List) { + if (orderedMapping.isEmpty()) { + // No playback, start playing these songs. + start(songs[0], songs, false) + return + } + + val heapIndices = songs.map(::addSongToHeap) + // Can simple append the new songs to the end of both mappings. + orderedMapping.addAll(heapIndices) + if (shuffledMapping.isNotEmpty()) { + shuffledMapping.addAll(heapIndices) + } + } + + /** + * Move a [Song] at the given position to a new position. + * @param src The position of the [Song] to move. + * @param dst The destination position of the [Song]. + */ + fun move(src: Int, dst: Int) { + if (shuffledMapping.isNotEmpty()) { + // Move songs only in the shuffled mapping. There is no sane analogous form of + // this for the ordered mapping. + shuffledMapping.add(dst, shuffledMapping.removeAt(src)) + } else { + // Move songs in the ordered mapping. + orderedMapping.add(dst, orderedMapping.removeAt(src)) + } + + if (index in (src + 1) until dst) { + // Index was ahead of moved song but not ahead of it's destination position. + // This makes it functionally a removal, so update the index to preserve consistency. + index -= 1 + } else if (index == src) { + // Moving the currently playing song. + index = dst + } + } + + /** + * Remove a [Song] at the given position. + * @param at The position of the [Song] to remove. + */ + fun remove(at: Int) { + if (shuffledMapping.isNotEmpty()) { + // Remove the specified index in the shuffled mapping and the analogous song in the + // ordered mapping. + orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at])) + shuffledMapping.removeAt(at) + } else { + // Remove the spe + orderedMapping.removeAt(at) + } + + // Note: We do not clear songs out from the heap, as that would require the backing data + // of the player to be completely invalidated. It's generally easier to not remove the + // song and retain player state consistency. + + if (index > at) { + // Index was ahead of removed song, shift back to preserve consistency. + index -= 1 + } + } + + private fun addSongToHeap(song: Song): Int { + // We want to first try to see if there are any "orphaned" songs in the queue + // that we can re-use. This way, we can reduce the memory used up by songs that + // were previously removed from the queue. + val currentMapping = orderedMapping + if (orderedMapping.isNotEmpty()) { + // While we could iterate through the queue and then check the mapping, it's + // faster if we first check the queue for all instances of this song, and then + // do a exclusion of this set of indices with the current mapping in order to + // obtain the orphaned songs. + val orphanCandidates = mutableSetOf() + for (entry in heap.withIndex()) { + if (entry.value == song) { + orphanCandidates.add(entry.index) + } + } + orphanCandidates.removeAll(currentMapping.toSet()) + if (orphanCandidates.isNotEmpty()) { + // There are orphaned songs, return the first one we find. + return orphanCandidates.first() + } + } + + // Nothing to re-use, add this song to the queue + heap.add(song) + return heap.lastIndex + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt index d1d28c3c3..090c81162 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager class MediaButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val playbackManager = PlaybackStateManager.getInstance() - if (playbackManager.song != null) { + if (playbackManager.queue.currentSong != null) { // We have a song, so we can assume that the service will start a foreground state. // At least, I hope. Again, *this is why we don't do this*. I cannot describe how // stupid this is with the state of foreground services on modern android. One 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 863d71b6b..3833bc188 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 @@ -36,6 +36,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD @@ -92,22 +93,29 @@ class MediaSessionComponent(private val context: Context, private val listener: // --- PLAYBACKSTATEMANAGER OVERRIDES --- - override fun onIndexMoved(index: Int) { - updateMediaMetadata(playbackManager.song, playbackManager.parent) + override fun onIndexMoved(queue: Queue) { + updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) invalidateSessionState() } - override fun onQueueChanged(queue: List) { + override fun onQueueChanged(queue: Queue) { updateQueue(queue) } - override fun onQueueReworked(index: Int, queue: List) { + override fun onQueueReworked(queue: Queue) { updateQueue(queue) invalidateSessionState() + mediaSession.setShuffleMode( + if (queue.isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() } - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { - updateMediaMetadata(playbackManager.song, parent) + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + updateMediaMetadata(playbackManager.queue.currentSong, parent) updateQueue(queue) invalidateSessionState() } @@ -131,23 +139,12 @@ class MediaSessionComponent(private val context: Context, private val listener: invalidateSecondaryAction() } - override fun onShuffledChanged(isShuffled: Boolean) { - mediaSession.setShuffleMode( - if (isShuffled) { - PlaybackStateCompat.SHUFFLE_MODE_ALL - } else { - PlaybackStateCompat.SHUFFLE_MODE_NONE - }) - - invalidateSecondaryAction() - } - // --- SETTINGS OVERRIDES --- override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { when (key) { context.getString(R.string.set_key_cover_mode) -> - updateMediaMetadata(playbackManager.song, playbackManager.parent) + updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction() } } @@ -219,16 +216,13 @@ class MediaSessionComponent(private val context: Context, private val listener: } override fun onSetShuffleMode(shuffleMode: Int) { - playbackManager.reshuffle( + playbackManager.reorder( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP, - settings) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } override fun onSkipToQueueItem(id: Long) { - if (id in playbackManager.queue.indices) { - playbackManager.goto(id.toInt()) - } + playbackManager.goto(id.toInt()) } override fun onCustomAction(action: String?, extras: Bundle?) { @@ -318,9 +312,9 @@ class MediaSessionComponent(private val context: Context, private val listener: * Upload a new queue to the [MediaSessionCompat]. * @param queue The current queue to upload. */ - private fun updateQueue(queue: List) { + private fun updateQueue(queue: Queue) { val queueItems = - queue.mapIndexed { i, song -> + queue.resolve().mapIndexed { i, song -> val description = MediaDescriptionCompat.Builder() // Media ID should not be the item index but rather the UID, @@ -350,7 +344,7 @@ class MediaSessionComponent(private val context: Context, private val listener: .intoPlaybackState(PlaybackStateCompat.Builder()) .setActions(ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. - .setActiveQueueItemId(playbackManager.index.toLong()) + .setActiveQueueItemId(playbackManager.queue.index.toLong()) // Android 13+ relies on custom actions in the notification. @@ -361,7 +355,7 @@ class MediaSessionComponent(private val context: Context, private val listener: PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INVERT_SHUFFLE, context.getString(R.string.desc_shuffle), - if (playbackManager.isShuffled) { + if (playbackManager.queue.isShuffled) { R.drawable.ic_shuffle_on_24 } else { R.drawable.ic_shuffle_off_24 @@ -391,7 +385,7 @@ class MediaSessionComponent(private val context: Context, private val listener: invalidateSessionState() when (settings.playbackNotificationAction) { - ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled) + ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled) else -> notification.updateRepeatMode(playbackManager.repeatMode) } 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 c615a27ab..521bb5949 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 @@ -351,12 +351,16 @@ class PlaybackService : } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, null, settings, true) + playbackManager.play(null, null, settings.libSongSort, true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { library.findSongForUri(application, action.uri)?.let { song -> - playbackManager.play(song, null, settings) + playbackManager.play( + song, + null, + settings.libSongSort, + playbackManager.queue.isShuffled && settings.keepShuffle) } } } @@ -411,8 +415,7 @@ class PlaybackService : playbackManager.setPlaying(!playbackManager.playerState.isPlaying) ACTION_INC_REPEAT_MODE -> playbackManager.repeatMode = playbackManager.repeatMode.increment() - ACTION_INVERT_SHUFFLE -> - playbackManager.reshuffle(!playbackManager.isShuffled, settings) + ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled) ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_NEXT -> playbackManager.next() ACTION_EXIT -> { @@ -428,7 +431,7 @@ class PlaybackService : // which would result in unexpected playback. Work around it by dropping the first // call to this function, which should come from that Intent. if (settings.headsetAutoplay && - playbackManager.song != null && + playbackManager.queue.currentSong != null && initialHeadsetPlugEventHandled) { logD("Device connected, resuming") playbackManager.setPlaying(true) @@ -436,7 +439,7 @@ class PlaybackService : } private fun pauseFromHeadsetPlug() { - if (playbackManager.song != null) { + if (playbackManager.queue.currentSong != null) { logD("Device disconnected, pausing") playbackManager.setPlaying(false) } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 638299bb9..42b99d989 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -30,6 +30,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getDimenPixels @@ -55,7 +56,7 @@ class WidgetComponent(private val context: Context) : /** Update [WidgetProvider] with the current playback state. */ fun update() { - val song = playbackManager.song + val song = playbackManager.queue.currentSong if (song == null) { logD("No song, resetting widget") widgetProvider.update(context, null) @@ -65,7 +66,7 @@ class WidgetComponent(private val context: Context) : // Note: Store these values here so they remain consistent once the bitmap is loaded. val isPlaying = playbackManager.playerState.isPlaying val repeatMode = playbackManager.repeatMode - val isShuffled = playbackManager.isShuffled + val isShuffled = playbackManager.queue.isShuffled provider.load( song, @@ -115,10 +116,10 @@ class WidgetComponent(private val context: Context) : // Hook all the major song-changing updates + the major player state updates // to updating the "Now Playing" widget. - override fun onIndexMoved(index: Int) = update() - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) = update() + override fun onIndexMoved(queue: Queue) = update() + override fun onQueueReworked(queue: Queue) = update() + override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() override fun onStateChanged(state: InternalPlayer.State) = update() - override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update() override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { From 3f0a532a2df3d639739ba2079e517f10c8866bd1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 09:20:21 -0700 Subject: [PATCH 05/55] music: allow editing past queue items Allow past and currently playing queue items to be edited, instead of just future queue items. This was a somewhat requested feature that was impossible with the prior queue system. With some fixes, the new queue system can now be used to do this. This even works with edge cases like removing the currently playing song. Albeit, it's likely that more bug fixes and testing will be needed. Resolves #223. --- CHANGELOG.md | 6 ++ .../auxio/playback/PlaybackViewModel.kt | 15 ++-- .../auxio/playback/queue/QueueAdapter.kt | 37 +++------- .../auxio/playback/queue/QueueDragCallback.kt | 18 +---- .../auxio/playback/queue/QueueViewModel.kt | 15 ++-- .../playback/state/PlaybackStateManager.kt | 62 ++++++++++------- .../org/oxycblt/auxio/playback/state/Queue.kt | 68 +++++++++++++++---- .../playback/system/MediaSessionComponent.kt | 17 +++-- .../oxycblt/auxio/widgets/WidgetComponent.kt | 2 +- 9 files changed, 145 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f14fdece8..173367fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## dev +#### What's Improved +- Added ability to edit previously played or currently playing items in the queue + #### What's Fixed - Fixed crash that would occur in music folders dialog when user does not have a working file manager +#### What's Changed +- Implemented new queue system + ## 3.0.1 #### What's New 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 0ae5f2765..6a453726b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -98,16 +98,23 @@ class PlaybackViewModel(application: Application) : } override fun onIndexMoved(queue: Queue) { - _song.value = playbackManager.queue.currentSong + _song.value = queue.currentSong } - override fun onQueueReworked(queue: Queue) { + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { + // Other types of queue changes preserve the current song. + if (change == Queue.ChangeResult.SONG) { + _song.value = queue.currentSong + } + } + + override fun onQueueReordered(queue: Queue) { _isShuffled.value = queue.isShuffled } override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - _song.value = playbackManager.queue.currentSong - _parent.value = playbackManager.parent + _song.value = queue.currentSong + _parent.value = parent _isShuffled.value = queue.isShuffled } 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 cb4ca8a58..d195b26e9 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 @@ -31,10 +31,7 @@ import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.context -import org.oxycblt.auxio.util.getAttrColorCompat -import org.oxycblt.auxio.util.getDimen -import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.* /** * A [RecyclerView.Adapter] that shows an editable list of queue items. @@ -96,30 +93,19 @@ class QueueAdapter(private val listener: EditableListListener) : * @param isPlaying Whether playback is ongoing or paused. */ fun setPosition(index: Int, isPlaying: Boolean) { - var updatedIndex = false + logD("Updating index") + val lastIndex = currentIndex + currentIndex = index - if (index != currentIndex) { - val lastIndex = currentIndex - currentIndex = index - updatedIndex = true - - // Have to update not only the currently playing item, but also all items marked - // as playing. - if (currentIndex < lastIndex) { - notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION) - } else { - notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION) - } + // Have to update not only the currently playing item, but also all items marked + // as playing. + if (currentIndex < lastIndex) { + notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION) + } else { + notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION) } - if (this.isPlaying != isPlaying) { - this.isPlaying = isPlaying - // Don't need to do anything if we've already sent an update from changing the - // index. - if (!updatedIndex) { - notifyItemChanged(index, PAYLOAD_UPDATE_POSITION) - } - } + this.isPlaying = isPlaying } private companion object { @@ -158,7 +144,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong binding.songAlbumCover.isEnabled = value binding.songName.isEnabled = value binding.songInfo.isEnabled = value - binding.songDragHandle.isEnabled = value } init { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 1fb220c9b..dc9eb13ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -35,21 +35,9 @@ import org.oxycblt.auxio.util.logD class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { private var shouldLift = true - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - val queueHolder = viewHolder as QueueSongViewHolder - return if (queueHolder.isFuture) { - makeFlag( - ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or - makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) - } else { - // Avoid allowing any touch actions for already-played queue items, as the playback - // system does not currently allow for this. - 0 - } - } + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = + makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or + makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) override fun onChildDraw( c: Canvas, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 44c227759..2141a47bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -24,6 +24,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Queue +import org.oxycblt.auxio.util.logD /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. @@ -79,9 +80,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * @return true if the items were moved, false otherwise. */ fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean { - if (adapterFrom <= playbackManager.queue.index || - adapterTo <= playbackManager.queue.index) { - // Invalid input. Nothing to do. + if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) { return false } playbackManager.moveQueueItem(adapterFrom, adapterTo) @@ -105,14 +104,18 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { _index.value = queue.index } - override fun onQueueChanged(queue: Queue) { - // Queue changed trivially due to item move -> Diff queue, stay at current index. + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. replaceQueue = false scrollTo = null _queue.value = queue.resolve() + if (change != Queue.ChangeResult.MAPPING) { + // Index changed, make sure it remains updated without actually scrolling to it. + _index.value = queue.index + } } - override fun onQueueReworked(queue: Queue) { + override fun onQueueReordered(queue: Queue) { // Queue changed completely -> Replace queue, update index replaceQueue = true scrollTo = queue.index 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 11f710456..c5a588226 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 @@ -226,11 +226,7 @@ class PlaybackStateManager private constructor() { * Add a [Song] to the top of the queue. * @param song The [Song] to add. */ - @Synchronized - fun playNext(song: Song) { - queue.playNext(listOf(song)) - notifyQueueChanged() - } + @Synchronized fun playNext(song: Song) = playNext(listOf(song)) /** * Add [Song]s to the top of the queue. @@ -238,19 +234,22 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun playNext(songs: List) { - queue.playNext(songs) - notifyQueueChanged() + val internalPlayer = internalPlayer ?: return + when (queue.playNext(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } } /** * Add a [Song] to the end of the queue. * @param song The [Song] to add. */ - @Synchronized - fun addToQueue(song: Song) { - queue.addToQueue(listOf(song)) - notifyQueueChanged() - } + @Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song)) /** * Add [Song]s to the end of the queue. @@ -258,8 +257,15 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun addToQueue(songs: List) { - queue.addToQueue(songs) - notifyQueueChanged() + val internalPlayer = internalPlayer ?: return + when (queue.addToQueue(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } } /** @@ -270,8 +276,7 @@ class PlaybackStateManager private constructor() { @Synchronized fun moveQueueItem(src: Int, dst: Int) { logD("Moving item $src to position $dst") - queue.move(src, dst) - notifyQueueChanged() + notifyQueueChanged(queue.move(src, dst)) } /** @@ -280,9 +285,13 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun removeQueueItem(at: Int) { + val internalPlayer = internalPlayer ?: return logD("Removing item at $at") - queue.remove(at) - notifyQueueChanged() + val change = queue.remove(at) + if (change == Queue.ChangeResult.SONG) { + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) + } + notifyQueueChanged(change) } /** @@ -292,7 +301,7 @@ class PlaybackStateManager private constructor() { @Synchronized fun reorder(shuffled: Boolean) { queue.reorder(shuffled) - notifyQueueReworked() + notifyQueueReordered() } // --- INTERNAL PLAYER FUNCTIONS --- @@ -532,15 +541,15 @@ class PlaybackStateManager private constructor() { } } - private fun notifyQueueChanged() { + private fun notifyQueueChanged(change: Queue.ChangeResult) { for (callback in listeners) { - callback.onQueueChanged(queue) + callback.onQueueChanged(queue, change) } } - private fun notifyQueueReworked() { + private fun notifyQueueReordered() { for (callback in listeners) { - callback.onQueueReworked(queue) + callback.onQueueReordered(queue) } } @@ -575,17 +584,18 @@ class PlaybackStateManager private constructor() { fun onIndexMoved(queue: Queue) {} /** - * Called when the [Queue] changed in a trivial manner, such as a move. + * Called when the [Queue] changed in a manner outlined by the given [Queue.ChangeResult]. * @param queue The new [Queue]. + * @param change The type of [Queue.ChangeResult] that occurred. */ - fun onQueueChanged(queue: Queue) {} + fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {} /** * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but * the currently playing [Song] has not. * @param queue The new [Queue]. */ - fun onQueueReworked(queue: Queue) {} + fun onQueueReordered(queue: Queue) {} /** * Called when a new playback configuration was created. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 8cb285d69..0c1551b9c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -115,12 +115,14 @@ class Queue { /** * Add [Song]s to the top of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. + * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there + * was no prior playback and these enqueued [Song]s start new playback. */ - fun playNext(songs: List) { + fun playNext(songs: List): ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return + return ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -134,17 +136,20 @@ class Queue { // Add the new song in front of the current index in the ordered mapping. orderedMapping.addAll(index, heapIndices) } + return ChangeResult.MAPPING } /** * Add [Song]s to the end of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. + * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there + * was no prior playback and these enqueued [Song]s start new playback. */ - fun addToQueue(songs: List) { + fun addToQueue(songs: List): ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return + return ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -153,14 +158,18 @@ class Queue { if (shuffledMapping.isNotEmpty()) { shuffledMapping.addAll(heapIndices) } + return ChangeResult.MAPPING } /** * Move a [Song] at the given position to a new position. * @param src The position of the [Song] to move. * @param dst The destination position of the [Song]. + * @return [ChangeResult.MAPPING] if the move occurred after the current index, + * [ChangeResult.INDEX] if the move occurred before or at the current index, requiring it to be + * mutated. */ - fun move(src: Int, dst: Int) { + fun move(src: Int, dst: Int): ChangeResult { if (shuffledMapping.isNotEmpty()) { // Move songs only in the shuffled mapping. There is no sane analogous form of // this for the ordered mapping. @@ -170,21 +179,31 @@ class Queue { orderedMapping.add(dst, orderedMapping.removeAt(src)) } - if (index in (src + 1) until dst) { + return when (index) { + // Moving the currently playing song. + src -> { + index = dst + ChangeResult.INDEX + } // Index was ahead of moved song but not ahead of it's destination position. // This makes it functionally a removal, so update the index to preserve consistency. - index -= 1 - } else if (index == src) { - // Moving the currently playing song. - index = dst + in (src + 1)..dst -> { + index -= 1 + ChangeResult.INDEX + } + // Nothing to do. + else -> ChangeResult.MAPPING } } /** * Remove a [Song] at the given position. * @param at The position of the [Song] to remove. + * @return [ChangeResult.MAPPING] if the removed [Song] was after the current index, + * [ChangeResult.INDEX] if the removed [Song] was before the current index, and + * [ChangeResult.SONG] if the currently playing [Song] was removed. */ - fun remove(at: Int) { + fun remove(at: Int): ChangeResult { if (shuffledMapping.isNotEmpty()) { // Remove the specified index in the shuffled mapping and the analogous song in the // ordered mapping. @@ -199,9 +218,16 @@ class Queue { // of the player to be completely invalidated. It's generally easier to not remove the // song and retain player state consistency. - if (index > at) { + return when { + // We just removed the currently playing song. + index == at -> ChangeResult.SONG // Index was ahead of removed song, shift back to preserve consistency. - index -= 1 + index > at -> { + index -= 1 + ChangeResult.INDEX + } + // Nothing to do + else -> ChangeResult.MAPPING } } @@ -232,4 +258,20 @@ class Queue { heap.add(song) return heap.lastIndex } + + /** + * Represents the possible changes that can occur during certain queue mutation events. The + * precise meanings of these differ somewhat depending on the type of mutation done. + */ + enum class ChangeResult { + /** Only the mapping has changed. */ + MAPPING, + /** The mapping has changed, and the index also changed to align with it. */ + INDEX, + /** + * The current song has changed, possibly alongside the mapping and index depending on the + * context. + */ + SONG + } } 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 3833bc188..b86aff5f9 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 @@ -94,15 +94,24 @@ class MediaSessionComponent(private val context: Context, private val listener: // --- PLAYBACKSTATEMANAGER OVERRIDES --- override fun onIndexMoved(queue: Queue) { - updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) + updateMediaMetadata(queue.currentSong, playbackManager.parent) invalidateSessionState() } - override fun onQueueChanged(queue: Queue) { + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { updateQueue(queue) + when (change) { + // Nothing special to do with mapping changes. + Queue.ChangeResult.MAPPING -> {} + // Index changed, ensure playback state's index changes. + Queue.ChangeResult.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + Queue.ChangeResult.SONG -> + updateMediaMetadata(queue.currentSong, playbackManager.parent) + } } - override fun onQueueReworked(queue: Queue) { + override fun onQueueReordered(queue: Queue) { updateQueue(queue) invalidateSessionState() mediaSession.setShuffleMode( @@ -115,7 +124,7 @@ class MediaSessionComponent(private val context: Context, private val listener: } override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - updateMediaMetadata(playbackManager.queue.currentSong, parent) + updateMediaMetadata(queue.currentSong, parent) updateQueue(queue) invalidateSessionState() } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 42b99d989..6b11f4488 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -117,7 +117,7 @@ class WidgetComponent(private val context: Context) : // Hook all the major song-changing updates + the major player state updates // to updating the "Now Playing" widget. override fun onIndexMoved(queue: Queue) = update() - override fun onQueueReworked(queue: Queue) = update() + override fun onQueueReordered(queue: Queue) = update() override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() override fun onStateChanged(state: InternalPlayer.State) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update() From 41dd9bf695bf2da423dec8c12f25adc6c4a1c6ac Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 5 Jan 2023 17:33:46 +0100 Subject: [PATCH 06/55] Translations update from Hosted Weblate (#308) * music: allow editing past queue items Allow past and currently playing queue items to be edited, instead of just future queue items. This was a somewhat requested feature that was impossible with the prior queue system. With some fixes, the new queue system can now be used to do this. This even works with edge cases like removing the currently playing song. Albeit, it's likely that more bug fixes and testing will be needed. Resolves #223. * Translated using Weblate (Spanish) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Croatian) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Ukrainian) Currently translated at 26.2% (64 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Spanish) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Russian) Currently translated at 99.5% (243 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 51.6% (126 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Ukrainian) Currently translated at 96.2% (26 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/ * Translated using Weblate (Ukrainian) Currently translated at 70.9% (173 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Ukrainian) Currently translated at 72.5% (177 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (German) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pt_BR/ * Translated using Weblate (German) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ Co-authored-by: Alexander Capehart Co-authored-by: gallegonovato Co-authored-by: Vaclovas lntas Co-authored-by: Eric Co-authored-by: Fjuro Co-authored-by: Milo Ivir Co-authored-by: BMN Co-authored-by: kirill blaze Co-authored-by: Ettore Atalan Co-authored-by: Edmundo Nocchi Co-authored-by: santiago046 Co-authored-by: qwerty287 Co-authored-by: Alexander Capehart --- app/src/main/res/values-cs/strings.xml | 6 +- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-hr/strings.xml | 2 + app/src/main/res/values-lt/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 20 +- app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 245 ++++++++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 1 + .../metadata/android/cs/full_description.txt | 2 +- .../metadata/android/de/full_description.txt | 2 +- .../android/es-ES/full_description.txt | 38 +-- .../metadata/android/lt/full_description.txt | 2 +- .../android/pt-BR/full_description.txt | 12 +- .../metadata/android/uk/full_description.txt | 22 ++ .../metadata/android/uk/short_description.txt | 1 + .../android/zh-CN/full_description.txt | 2 +- 17 files changed, 309 insertions(+), 54 deletions(-) create mode 100644 fastlane/metadata/android/uk/full_description.txt create mode 100644 fastlane/metadata/android/uk/short_description.txt diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3bc30c7de..b59774f52 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -54,13 +54,13 @@ Nastavení Vzhled - Téma + Motiv Automatické Světlé Tmavé Barevné schéma - Černé téma - Použít kompletně černé tmavé téma + Černý motiv + Použít kompletně černý tmavý motiv Zobrazení Karty knihovny Změnit viditelnost a pořadí karet knihovny diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 02eac70a1..852469d09 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -260,4 +260,5 @@ Vom Genre abspielen Wiki %1$s, %2$s + Zurücksetzen \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 519b81be3..35484a777 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -128,10 +128,12 @@ Canciones cargadas: %d %d canción + %d canciones %d canciones %d álbum + %d álbumes %d álbumes Tamaño @@ -262,4 +264,5 @@ Reproducir desde el género Wiki %1$s, %2$s + Restablecer \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 6a2ad98a1..bfd011990 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -254,4 +254,6 @@ Reproduciraj odabrane Reproduciraj iz žanra Wiki + %1$s, %2$s + Resetiraj \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index e43b9b21c..2885c53a1 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -258,4 +258,5 @@ Groti iš žanro Viki %1$s, %2$s + Nustatyti iš naujo \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6ee2cd8e1..eb66c994d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -25,7 +25,7 @@ Versão Código-fonte Licenças - Desenvolvido por OxygenCobalt + Desenvolvido por Alexander Capehart Configurações Aparência @@ -62,10 +62,12 @@ Músicas carregadas: %d %d música + %d músicas %d músicas %d álbum + %d álbuns %d álbuns Crescente @@ -91,7 +93,7 @@ Retrocede a música antes de voltar para a anterior ‘As músicas serão carregadas somente das pastas que você adicionar. Excluir - As músicas não serão carregadas das pastas que você adicionar + As músicas não serão carregadas das pastas que você adicionar. Modo O Auxio precisa de permissão para ler sua biblioteca de músicas Um reprodutor de música simples e racional para android. @@ -147,7 +149,7 @@ Áudio Ogg Áudio Matroska Codificação de Audio Avançada (AAC) - Codec gratuito de áudio sem perdas (FLAC) + Free Lossless Audio Codec (FLAC) Mover esta música da fila Dinâmico Duração total: %s @@ -169,7 +171,7 @@ Preferir álbum Prefira o álbum se estiver tocando Recarregamento automático - Recarrega a biblioteca de músicas sempre que ela mudar (requer notificação persistente) + Recarrega a biblioteca de músicas sempre que ela mudar (requer notificação fixa) Data adicionada Cancelar Preferir faixa @@ -245,12 +247,20 @@ Mix %d artista + %d artistas %d artistas Re-escanear músicas - Limpa os metadados em cache e recarrega totalmente a biblioteca de músicas (mais lento, porém mais completo) + Limpa os metadados em cache e recarrega totalmente a biblioteca de música (lento, porém mais completo) Não foi possível limpar a lista Não foi possível salvar a lista Ocultar artistas colaboradores Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos) + Tocar selecionada(s) + Aleatorizar selecionadas + %d Selecionadas + Wiki + Redefinir + %1$s, %2$s + Tocar a partir do gênero \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index db6b0081f..c1794ba74 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -264,4 +264,7 @@ Воспроизвести выбранное Перемешать выбранное %d Выбрано + Вики + Сбросить + %1$s,%2$s \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 886a9d6ec..1cf923333 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -6,11 +6,11 @@ Жанри Виконавці Альбоми - Треки - Треки - Шукати + Пісні + Всі пісні + Пошук Фільтр - Усе + Все Сортування Відтворити Перемішати @@ -18,12 +18,12 @@ Черга Відтворити наступною Додати в чергу - Додана в чергу + Додано в чергу Перейти до виконавця Перейти до альбому Про програму Версія - Переглянути на GitHub + Вихідний код Ліцензії Налаштування @@ -36,22 +36,233 @@ Музику не знайдено - Пошук бібліотеки… + Пошук в бібліотеці… - Доріжка %d + Пісня %d Відтворити/Зупинити - Пісні завантажено: %d + Завантажено пісень: %d - %d Пісня - %d Пісні - %d Пісень - %d Пісень + %d пісня + %d пісні + %d пісень + %d пісень - %d Альбом - %d Альбоми - %d Альбомів - %d Альбомів + %d альбом + %d альбоми + %d альбомів + %d альбомів + Перемішати вибране + Ім\'я файлу + Формат + OK + Відміна + Зберегти + Перемішати + Перемішати все + Саундтреки + Саундтрек + Еквалайзер + Розмір + Завантаження музики + Збірки реміксів + Бітрейт + Моніторинг музичної бібліотеки + Простий, раціональний музичний плеєр на Android. + Завантаження пісень + Альбом + Сингли + Сингл + Альбом реміксів + Сингл ремікс + Збірки + Збірка + Концертний альбом + Шлях до каталогу + Екран + Рік + Відтворити вибране + Обкладинки альбомів + Приховати співавторів + Вимкнено + Перейти до наступної + Швидкі + Вкажіть папки, з яких програма має завантажувати пісні + Виключити + Загальна тривалість: %s + Мікстейпи + Виконавець + Тривалість + Мікси + Мікс + Заокруглені обкладинки + Завантажено жанрів: %d + Властивості пісні + Додати + Ставити на паузу при повторенні пісні + Дата додавання + Частота дискретизації + Висока якість + Оновити музику + Ремікси + Мікстейп + Папки з музикою + Скинути + Чорна тема + Зміна видимості та порядку вкладок бібліотеки + Запам\'ятати перемішування + Пауза при повторенні + Жанр + Диск + По зростанню + Назва + Переглянути властивості + Ігнорувати аудіо файли які не являються музикою, наприклад, подкасти + Виключити інші звукові файли + Включити + Завантажено альбомів: %d + Концертна збірка + Мініальбом + Мініальбоми + Номер пісні + Завжди починати відтворення при підключенні гарнітури (може працювати не на всіх пристроях) + Мініальбом реміксів + Концертний сингл + Наживо + Концертний мініальбом + Статистика бібліотеки + Завантаження музичної бібліотеки… + Розроблено Олександром Кейпхартом + Завантажено виконавців: %d + Автоматично + Кольоровий акцент + Вкладки бібліотеки + Автовідтворення в навушниках + Режим повторення + Режим + Попередній підсилювач ReplayGain + Відтворити альбом + При відтворенні з бібліотеки + Віддавати перевагу альбому, якщо він відтворюється + Стан відтворення очищено + Використовувати повністю чорну тему + Показувати лише тих виконавців, які безпосередньо зазначені в альбомі (найкраще працює в добре позначених бібліотеках) + Увага: Встановлення високих позитивних значень попереднього підсилювача може призвести до обрізки звуку в деяких піснях. + При відтворенні з деталей предмета + Очистити кеш тегів і повністю перезавантажити музичну бібліотеку (повільніше, але ефективніше) + Автоматичне перезавантаження + Перезавантажувати бібліотеку при виявленні змін (потрібне постійне сповіщення) + Музика не буде завантажена з вибраних папок. + Налаштуйте символи, які позначають кілька значень тегів + Стан відтворення збережено + За альбомом + За піснею + Зміст + Очистити раніше збережений стан відтворення (якщо є) + Відстеження змін в музичній бібліотеці… + Власна дія для панелі відтворення + Регулювання без тегів + Відтворення з показаного елемента + Продовжити перемішування після вибору нової пісні + Відтворити виконавця + Відтворити жанр + Перемотати назад перед відтворенням попередньої пісні + Зберегти поточний стан відтворення + Пересканувати музику + Попередження: Використання цього параметра може призвести до того, що деякі теги будуть неправильно інтерпретовані як такі, що мають кілька значень. Ви можете вирішити це, додавши перед небажаними символами-роздільниками зворотну скісну риску (\\). + Кнопка в сповіщенні + Багатозначні роздільники + Музика буде завантажена тільки з вибраних папок. + Відновити раніше збережений стан відтворення (якщо є) + Регулювання на основі тегів + Вирівнювання гучності (ReplayGain) + Зберегти стан відтворення + Очистити стан відтворення + Відновити стан відтворення + Пісня + Стан відтворення відновлено + Перегляд і керування відтворенням музики + Перемотайте на початок пісні перед відтворенням попередньої + Увімкнути закруглені кути на додаткових елементах інтерфейсу (потрібно заокруглення обкладинок альбомів) + Попередній підсилювач застосовується до наявних налаштувань під час відтворення + Відтворити всі пісні + Перезавантажити музичну бібліотеку, використовуючи кешовані теги, коли це можливо + Скісна риска (/) + Плюс (+) + Кома (,) + Крапка з комою (;) + Ця папка не підтримується + Невідомий виконавець + Немає бітрейту + Лаймовий + Амперсанд (&) + Немає папок + Не вдалось відновити статус відтворення + Перейти до наступної пісні + Ввімкніть або вимкніть перемішування + Зупинити відтворення + Free Lossless Audio Codec (FLAC) + Темно-фіолетовий + %d Вибрано + Завантаження музичної бібліотеки… (%1$d/%2$d) + %d кбіт/с + %d Гц + + %d виконавець + %d виконавці + %d виконавців + %d виконавців + + Обкладинка альбому %s + Невідомий жанр + Немає частоти дискретизації + Відкрити чергу + Жовтий + Перемістити пісню в черзі + Видалити пісню з черги + Блакитний + Зеленувато-блакитний + Фіолетовий + Вікі + Змінити режим повтору + Перемістити дану вкладку + Очистити пошуковий запит + Обкладинка альбому + Ogg audio + Індиго + Синій + Темно-синій + Зелений + Темно-зелений + Динамічні + +%.1f дБ + -%.1f дБ + Фото виконавця %s + Невідомий формат + Зображення жанру %s + MPEG-1 audio + Не знайдено жодної програми, яка б могла впоратися з цим завданням + Дата відсутня + Номер пісні невідомий + Музика не грає + Не вдалось очистити статус відтворення + Не вдалось зберегти статус відтворення + Перейти до попередньої пісні + Червоний + MPEG-4 audio + Перемішати всі пісні + Іконка Auxio + Рожевий + Помаранчевий + Коричневий + Сірий + Диск %d + Не вдалося завантажити музику + Видалити папку + Advanced Audio Coding (AAC) + Matroska audio + %1$s, %2$s + Auxio потрібен дозвіл на читання вашої музичної бібліотеки \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5086dd21a..2b9deb466 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -258,4 +258,5 @@ 按流派播放 Wiki %1$s, %2$s + 重置 \ No newline at end of file diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index 8be30710b..27f7e3da3 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -1,4 +1,4 @@ -Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na systému Exoplayer má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu. +Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na systému Exoplayer má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu. Funkce diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 62bec83d5..a5e9581e5 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,4 +1,4 @@ -Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und hat deshalb eine bessere Musik-Bibliotheks-Unterstützung und Qualität als andere Player, die die veralteten Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. +Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und hat deshalb eine bessere Musik-Bibliotheks-Unterstützung und Qualität als andere Player, die die veralteten Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. Funktionen diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 0f5338585..436d6f8e7 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,22 +1,22 @@ -Auxio es un reproductor de música local con una UI/UX rápida y confiable sin muchas funciones innecesarias que tienen otros reproductores de música. Basado en Exoplayer, Auxio tiene un mejor soporte para la biblioteca y una excelente calidad de sonido en comparación con otras aplicaciones que usan una función de Android obsoleta. En resumen, solo reproduce música. +Auxio es un reproductor de música nativo con una UI/UX rápida y sólida sin las muchas características inútiles que se encuentran en otros reproductores de música. En comparación con otras aplicaciones que utilizan la API nativa de MediaPlayer, Auxio se basa en Exoplayer, con una mejor experiencia auditiva y una mejor compatibilidad con la biblioteca de música, exactamente como debería ser un reproductor de música. -Funciones +Características -- Reproductor basado en el sistema ExoPlayer -- Interfaz de usuario receptiva de acuerdo con las últimas pautas de Diseño de materiales -- UX agradable que prioriza la facilidad de uso sobre los casos extremos -- Comportamiento personalizable -- Admite números de disco, múltiples artistas, tipos de lanzamiento, -datos exactos/originales, clasificación de etiquetas y más -- Sistema de artista avanzado que conecta artistas y artistas de álbumes -- Gestión de carpetas compatible con tarjetas SD -- Almacenamiento confiable del estado de reproducción -- Compatibilidad total con ReplayGain (para archivos MP3, FLAC, OGG, OPUS y MP4) -- Soporte para reproductores externos (por ejemplo, Wavelet) -- Borde a borde -- Soporte para empaquetado integrado -- Buscando función +- Reproducción basada en ExoPlayer +- Interfaz inteligente derivada de las últimas especificaciones de diseño de Material You +- Una experiencia de usuario única que prioriza la facilidad de uso +- Comportamiento del jugador personalizable +- Admite número de disco, varios artistas, tipo de lanzamiento, fecha exacta/original, +Clasificación de pestañas y más +- Sistema avanzado de "Artista" que unifica "Artista" y "Artista del álbum" +- La función de gestión de carpetas es consciente de la tarjeta SD +- Diseño persistente confiable del progreso de la reproducción +- Compatibilidad completa con ReplayGain (incluidos archivos MP3, FLAC, OGG, OPUS y MP4) +- Soporte para ecualizadores externos (aplicaciones como Wavelet) +- Diseño de borde a borde +- Soporte de cubierta integrado +- buscando función - Reproducción automática cuando los auriculares están conectados -- Widgets con estilo que se adaptan automáticamente a su tamaño -- Totalmente privado y fuera de línea -- No hay portadas de álbumes redondeadas (a menos que las quieras. De lo contrario, están disponibles) +- Widgets estilizados que se adaptan al tamaño del escritorio +- Completamente fuera de línea y privado +- Carátula del álbum sin esquinas redondeadas (también puedes tener eso si quieres) diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt index 3e8a2ccf2..9e1d1fb30 100644 --- a/fastlane/metadata/android/lt/full_description.txt +++ b/fastlane/metadata/android/lt/full_description.txt @@ -1,4 +1,4 @@ -„Auxio“ yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis „Exoplayer“, „Auxio“ turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias „Android“ funkcijas. Trumpai tariant, Jame groja muziką. +„Auxio“ yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis „Exoplayer“, „Auxio“ turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias „Android“ funkcijas. Trumpai tariant, Jame groja muziką. Funkcijos diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt index 41c5c5056..bff79436f 100644 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -1,16 +1,16 @@ -O Auxio é um reprodutor de música local com UI/UX rápido e confiável, sem os muitos recursos inúteis presentes em outros reprodutores de música. Construído a partir do Exoplayer, o Auxio tem uma experiência de audição muito melhor em comparação com outros aplicativos que usam a API MediaPlayer nativa. Resumindo, Toca música. +O Auxio é um reprodutor de música local com UI/UX rápido e confiável, sem os muitos recursos inúteis presentes em outros reprodutores de música. Construído a partir do Exoplayer, o Auxio tem uma experiência de audição muito melhor em comparação com outros aplicativos que usam a API MediaPlayer nativa. Resumindo, Toca música. Recursos - Reprodução baseada em ExoPlayer - Snappy UI derivada das diretrizes mais recentes do Material Design -- UX opinativo que prioriza a facilidade de uso em relação aos casos extremos +- UX opinativo que prioriza a facilidade de uso - Comportamento personalizável -- Indexador de mídia avançado que prioriza metadados corretos -- Suporte à Datas Precisas/Originais, Classificação de Metadados e Tipos de Lançamento (experimental) +- Suporte à numeração de músicas nos discos, múltiplos artistas, tipos de lançamento, datas precisas/originais, ordenar tags, e mais +- Sistema de artistas avançado que unifica artistas e seus álbuns - Gerenciamento de pastas com reconhecimento de cartão SD - Persistência confiável da lista de reprodução -- Suporte completo ao ReplayGain (em MP3, MP4, FLAC, OGG e OPUS) +- Suporte completo ao ReplayGain (MP3, MP4, FLAC, OGG e OPUS) - Funcionalidade de equalizador externo (como o Wavelet) - De ponta a ponta - Suporte para capas embutidas @@ -18,4 +18,4 @@ O Auxio é um reprodutor de música local com UI/UX rápido e confiável, sem os - Reprodução automática em fones de ouvido - Widgets elegantes que se adaptam automaticamente ao seu tamanho - Completamente privado e offline -- Sem capas de álbuns arredondadas (a menos que você as queira. Então você pode.) +- Sem capas de álbuns arredondadas (a menos que você as queira. Daí pode.) diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt new file mode 100644 index 000000000..0fbe137bf --- /dev/null +++ b/fastlane/metadata/android/uk/full_description.txt @@ -0,0 +1,22 @@ +Auxio — це локальний музичний плеєр із швидким і надійним UI/UX без багатьох марних функцій, наявних в інших музичних плеєрах. Створений на основі Exoplayer, Auxio має кращу підтримку бібліотеки та якість прослуховування порівняно з іншими програмами, які використовують застарілі функції Android. Якщо коротко, він відтворює музику. + +Особливості + +- Відтворення на основі ExoPlayer +- Швидкий UI створений відповідно до останніх рекомендацій Material Design +- Продуманий UX, який надає перевагу простоті використання над крайнощами +- Настроювана поведінка +- Підтримка номерів дисків, кількох виконавців, типів випусків, +точні/оригінальні дати, теги сортування тощо +- Розширена система виконавців, яка об’єднує виконавців і виконавців альбомів +- Керування папками на SD-карті +- Надійне збереження стану відтворення +- Повна підтримка ReplayGain (у файлах MP3, FLAC, OGG, OPUS і MP4) +- Підтримка зовнішнього еквалайзера (наприклад, Wavelet) +- Дизайн від краю до краю +- Підтримка вбудованих обкладинок +- Функціональний пошук +- Автоматичне відтворення в навушниках +- Адаптивні віджети +- Повністю приватний і офлайн +- Жодних заокруглених обкладинок альбомів (якщо ви їх не хочете) diff --git a/fastlane/metadata/android/uk/short_description.txt b/fastlane/metadata/android/uk/short_description.txt new file mode 100644 index 000000000..9ae145c1b --- /dev/null +++ b/fastlane/metadata/android/uk/short_description.txt @@ -0,0 +1 @@ +Простий, раціональний музичний плеєр diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index cdc96866c..2b2c9a45a 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -1,4 +1,4 @@ -Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于 Exoplayer 进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子。 +Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于 Exoplayer 进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子功能特性 From c0f1b9423f98147fdb3fc7081ea3bfca836f3be8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 10:04:19 -0700 Subject: [PATCH 07/55] music: band-aid queue move issues Band-aid certain types of queue moves that will fail to cauase the index to correct. Just do this by assuming all queue moves are swaps and then forgetting about it. I'll need to figure out how to "properly" do this eventually. --- .../org/oxycblt/auxio/playback/queue/QueueViewModel.kt | 1 - .../java/org/oxycblt/auxio/playback/state/Queue.kt | 10 ++++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 2141a47bd..5c5e5d8d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -24,7 +24,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Queue -import org.oxycblt.auxio.util.logD /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 0c1551b9c..333d5e189 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -170,6 +170,7 @@ class Queue { * mutated. */ fun move(src: Int, dst: Int): ChangeResult { + if (shuffledMapping.isNotEmpty()) { // Move songs only in the shuffled mapping. There is no sane analogous form of // this for the ordered mapping. @@ -179,19 +180,16 @@ class Queue { orderedMapping.add(dst, orderedMapping.removeAt(src)) } + // TODO: I really need to figure out how to get non-swap moves working. return when (index) { - // Moving the currently playing song. src -> { index = dst ChangeResult.INDEX } - // Index was ahead of moved song but not ahead of it's destination position. - // This makes it functionally a removal, so update the index to preserve consistency. - in (src + 1)..dst -> { - index -= 1 + dst -> { + index = src ChangeResult.INDEX } - // Nothing to do. else -> ChangeResult.MAPPING } } From 743220d0aae96c438ac558fef928cd0d107eb877 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 11:03:30 -0700 Subject: [PATCH 08/55] replaygain: revert back to copy no-op Turns out using isActive to indicate that the AudioProcessor is a no-op is too unreliable due to how they are managed internally. Instead, I really do just have to use a copy. Once again ExoPlayer picks the most absurd possible design choices for no good reason. Resolves #293. --- CHANGELOG.md | 1 + .../replaygain/ReplayGainAudioProcessor.kt | 63 +++++++++---------- .../org/oxycblt/auxio/playback/state/Queue.kt | 1 - 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 173367fb4..387549954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Added ability to edit previously played or currently playing items in the queue #### What's Fixed +- Fixed unreliable ReplayGain adjustment application in certain situations - Fixed crash that would occur in music folders dialog when user does not have a working file manager 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 77a633140..5c09579b6 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 @@ -229,49 +229,44 @@ class ReplayGainAudioProcessor(private val context: Context) : throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) } - override fun isActive() = super.isActive() && volume != 1f - override fun queueInput(inputBuffer: ByteBuffer) { - val position = inputBuffer.position() + val pos = inputBuffer.position() val limit = inputBuffer.limit() - val size = limit - position - val buffer = replaceOutputBuffer(size) + val buffer = replaceOutputBuffer(limit - pos) - for (i in position until limit step 2) { - // Ensure we clamp the values to the minimum and maximum values possible - // for the encoding. This prevents issues where samples amplified beyond - // 1 << 16 will end up becoming truncated during the conversion to a short, - // resulting in popping. - var sample = inputBuffer.getLeShort(i) - sample = - (sample * volume) - .toInt() - .coerceAtLeast(Short.MIN_VALUE.toInt()) - .coerceAtMost(Short.MAX_VALUE.toInt()) - .toShort() - buffer.putLeShort(sample) + if (volume == 1f) { + // Nothing to adjust, just copy the audio data. + // isActive is technically a much better way of doing a no-op like this, but since + // the adjustment can change during playback I'm largely forced to do this. + buffer.put(inputBuffer.slice()) + } else { + for (i in pos until limit step 2) { + // 16-bit PCM audio, deserialize a little-endian short. + var sample = + inputBuffer + .get(i + 1) + .toInt() + .shl(8) + .or(inputBuffer.get(i).toInt().and(0xFF)) + .toShort() + // Ensure we clamp the values to the minimum and maximum values possible + // for the encoding. This prevents issues where samples amplified beyond + // 1 << 16 will end up becoming truncated during the conversion to a short, + // resulting in popping. + sample = + (sample * volume) + .toInt() + .coerceAtLeast(Short.MIN_VALUE.toInt()) + .coerceAtMost(Short.MAX_VALUE.toInt()) + .toShort() + buffer.put(sample.toByte()).put(sample.toInt().shr(8).toByte()) + } } inputBuffer.position(limit) buffer.flip() } - /** - * Always read a little-endian [Short] from the [ByteBuffer] at the given index. - * @param at The index to read the [Short] from. - */ - private fun ByteBuffer.getLeShort(at: Int) = - get(at + 1).toInt().shl(8).or(get(at).toInt().and(0xFF)).toShort() - - /** - * Always write a little-endian [Short] at the end of the [ByteBuffer]. - * @param short The [Short] to write. - */ - private fun ByteBuffer.putLeShort(short: Short) { - put(short.toByte()) - put(short.toInt().shr(8).toByte()) - } - /** * The resolved ReplayGain adjustment for a file. * @param track The track adjustment (in dB), or 0 if it is not present. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 333d5e189..025485f53 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -170,7 +170,6 @@ class Queue { * mutated. */ fun move(src: Int, dst: Int): ChangeResult { - if (shuffledMapping.isNotEmpty()) { // Move songs only in the shuffled mapping. There is no sane analogous form of // this for the ordered mapping. From a10cf1e0c3040863438625357576842a6e39700a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 11:38:32 -0700 Subject: [PATCH 09/55] music: fix enqueueing Fix enqueuing issues encountered during testing. --- .../replaygain/ReplayGainAudioProcessor.kt | 26 +++++++++++++------ .../playback/state/PlaybackStateManager.kt | 4 +++ .../org/oxycblt/auxio/playback/state/Queue.kt | 10 +++---- 3 files changed, 27 insertions(+), 13 deletions(-) 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 5c09579b6..ecd8a1eec 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 @@ -242,13 +242,7 @@ class ReplayGainAudioProcessor(private val context: Context) : } else { for (i in pos until limit step 2) { // 16-bit PCM audio, deserialize a little-endian short. - var sample = - inputBuffer - .get(i + 1) - .toInt() - .shl(8) - .or(inputBuffer.get(i).toInt().and(0xFF)) - .toShort() + var sample = inputBuffer.getLeShort(i) // Ensure we clamp the values to the minimum and maximum values possible // for the encoding. This prevents issues where samples amplified beyond // 1 << 16 will end up becoming truncated during the conversion to a short, @@ -259,7 +253,7 @@ class ReplayGainAudioProcessor(private val context: Context) : .coerceAtLeast(Short.MIN_VALUE.toInt()) .coerceAtMost(Short.MAX_VALUE.toInt()) .toShort() - buffer.put(sample.toByte()).put(sample.toInt().shr(8).toByte()) + buffer.putLeShort(sample) } } @@ -267,6 +261,22 @@ class ReplayGainAudioProcessor(private val context: Context) : buffer.flip() } + /** + * Always read a little-endian [Short] from the [ByteBuffer] at the given index. + * @param at The index to read the [Short] from. + */ + private fun ByteBuffer.getLeShort(at: Int) = + get(at + 1).toInt().shl(8).or(get(at).toInt().and(0xFF)).toShort() + + /** + * Always write a little-endian [Short] at the end of the [ByteBuffer]. + * @param short The [Short] to write. + */ + private fun ByteBuffer.putLeShort(short: Short) { + put(short.toByte()) + put(short.toInt().shr(8).toByte()) + } + /** * The resolved ReplayGain adjustment for a file. * @param track The track adjustment (in dB), or 0 if it is not present. 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 c5a588226..639fa6381 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 @@ -238,6 +238,8 @@ class PlaybackStateManager private constructor() { when (queue.playNext(songs)) { Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null internalPlayer.loadSong(queue.currentSong, true) notifyNewPlayback() } @@ -261,6 +263,8 @@ class PlaybackStateManager private constructor() { when (queue.addToQueue(songs)) { Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null internalPlayer.loadSong(queue.currentSong, true) notifyNewPlayback() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 025485f53..045bb4110 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -102,7 +102,7 @@ class Queue { // Since we are re-shuffling existing songs, we use the previous mapping size // instead of the total queue size. - shuffledMapping = MutableList(orderedMapping.size) { it }.apply { shuffle() } + shuffledMapping = orderedMapping.shuffled().toMutableList() shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex))) index = 0 } else if (shuffledMapping.isNotEmpty()) { @@ -130,11 +130,11 @@ class Queue { // Add the new songs in front of the current index in the shuffled mapping and in front // of the analogous list song in the ordered mapping. val orderedIndex = orderedMapping.indexOf(shuffledMapping[index]) - orderedMapping.addAll(orderedIndex, heapIndices) - shuffledMapping.addAll(index, heapIndices) + orderedMapping.addAll(orderedIndex + 1, heapIndices) + shuffledMapping.addAll(index + 1, heapIndices) } else { // Add the new song in front of the current index in the ordered mapping. - orderedMapping.addAll(index, heapIndices) + orderedMapping.addAll(index + 1, heapIndices) } return ChangeResult.MAPPING } @@ -207,7 +207,7 @@ class Queue { orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at])) shuffledMapping.removeAt(at) } else { - // Remove the spe + // Remove the specified index in the shuffled mapping orderedMapping.removeAt(at) } From 78e3739a682edbc1ceffa31b7134c06bb3e4c73e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 12:27:37 -0700 Subject: [PATCH 10/55] testing: add unit test framework Add junit/espresso alongside a few basic tests. I am nowhere near close to being "done" with this at all. --- app/build.gradle | 13 ++- .../java/org/oxycblt/auxio/StubTest.kt | 32 +++++++ .../main/java/org/oxycblt/auxio/music/Date.kt | 2 +- .../java/org/oxycblt/auxio/music/DateTest.kt | 80 ++++++++++++++++++ .../auxio/music/parsing/ParsingUtilTest.kt | 83 +++++++++++++++++++ 5 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/DateTest.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt diff --git a/app/build.gradle b/app/build.gradle index b0662424f..59515810f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,9 +18,7 @@ android { minSdk 21 targetSdk 33 - buildFeatures { - viewBinding true - } + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } // ExoPlayer, AndroidX, and Material Components all need Java 8 to compile. @@ -47,6 +45,10 @@ android { } } + buildFeatures { + viewBinding true + } + dependenciesInfo { includeInApk = false includeInBundle = false @@ -110,8 +112,11 @@ dependencies { // Locked below 1.7.0-alpha03 to avoid the same ripple bug implementation "com.google.android.material:material:1.7.0-alpha02" - // LeakCanary + // Development debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1" + testImplementation "junit:junit:4.13.2" + androidTestImplementation 'androidx.test.ext:junit:1.1.4' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' } spotless { diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt new file mode 100644 index 000000000..72626319e --- /dev/null +++ b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class StubTest { + // TODO: Add tests +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt index 2e05ef5a4..aa2783943 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt @@ -74,7 +74,7 @@ class Date private constructor(private val tokens: List) : Comparable override fun hashCode() = tokens.hashCode() - override fun equals(other: Any?) = other is Date && tokens == other.tokens + override fun equals(other: Any?) = other is Date && other.compareTo(this) == 0 override fun compareTo(other: Date): Int { for (i in 0 until max(tokens.size, other.tokens.size)) { diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt new file mode 100644 index 000000000..c0ae14f95 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DateTest { + // TODO: Incomplete + + @Test + fun date_fromYear() { + assertEquals(Date.from(2016).toString(), "2016") + } + + @Test + fun date_fromDate() { + assertEquals(Date.from(2016, 8, 16).toString(), "2016-08-16") + } + + @Test + fun date_fromDatetime() { + assertEquals(Date.from(2016, 8, 16, 0, 1).toString(), "2016-08-16T00:01Z") + } + + @Test + fun date_fromFormalTimestamp() { + assertEquals(Date.from("2016-08-16T00:01:02").toString(), "2016-08-16T00:01:02Z") + } + + @Test + fun date_fromSpacedTimestamp() { + assertEquals(Date.from("2016-08-16 00:01:02").toString(), "2016-08-16T00:01:02Z") + } + + @Test + fun date_fromDatestamp() { + assertEquals(Date.from("2016-08-16").toString(), "2016-08-16") + } + + @Test + fun date_fromWeirdDateTimestamp() { + assertEquals(Date.from("2016-08-16T00:01").toString(), "2016-08-16T00:01Z") + } + + @Test + fun date_fromWeirdDatestamp() { + assertEquals(Date.from("2016-08").toString(), "2016-08") + } + + @Test + fun date_fromYearStamp() { + assertEquals(Date.from("2016").toString(), "2016") + } + + @Test + fun date_fromWackTimestamp() { + assertEquals(Date.from("2016-11-32 25:43:01").toString(), "2016-11") + } + + @Test + fun date_fromWackYear() { + assertEquals(Date.from(0), null) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt new file mode 100644 index 000000000..dd9b0de79 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.parsing + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ParsingUtilTest { + @Test + fun splitEscaped_correct() { + assertEquals("a,b,c".splitEscaped { it == ',' }, listOf("a", "b", "c")) + } + + @Test + fun splitEscaped_escaped() { + assertEquals("a\\,b,c".splitEscaped { it == ',' }, listOf("a,b", "c")) + } + + @Test + fun splitEscaped_whitespace() { + assertEquals("a , b, c , ".splitEscaped { it == ',' }, listOf("a ", " b", " c ", " ")) + } + + @Test + fun splitEscaped_escapedWhitespace() { + assertEquals("a \\, b, c , ".splitEscaped { it == ',' }, listOf("a , b", " c ", " ")) + } + + @Test + fun correctWhitespace_stringCorrect() { + assertEquals( + " asymptotic self-improvement ".correctWhitespace(), "asymptotic self-improvement") + } + + @Test + fun correctWhitespace_stringOopsAllWhitespace() { + assertEquals(" ".correctWhitespace(), null) + } + + @Test + fun correctWhitespace_listCorrect() { + assertEquals( + listOf(" asymptotic self-improvement ", " daemons never stop", "tcp phagocyte") + .correctWhitespace(), + listOf("asymptotic self-improvement", "daemons never stop", "tcp phagocyte")) + } + + @Test + fun correctWhitespace_listOopsAllWhitespacE() { + assertEquals( + listOf(" ", "", " tcp phagocyte").correctWhitespace(), listOf("tcp phagocyte")) + } + + @Test + fun parseId3v2Position_correct() { + assertEquals("16/32".parseId3v2Position(), 16) + } + + @Test + fun parseId3v2Position_noTotal() { + assertEquals("16".parseId3v2Position(), 16) + } + + @Test + fun parseId3v2Position_wack() { + assertEquals("16/".parseId3v2Position(), 16) + } +} From a5e78e614f0e15e65dd1906f378599e96c4066fe Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 12:52:09 -0700 Subject: [PATCH 11/55] music: complete non-android date tests Complete the date tests that don't require a context. --- CHANGELOG.md | 3 + .../main/java/org/oxycblt/auxio/music/Date.kt | 2 +- .../java/org/oxycblt/auxio/music/DateTest.kt | 96 ++++++++++++++++--- .../auxio/music/parsing/ParsingUtilTest.kt | 28 +++--- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387549954..6bbbf847e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ file manager #### What's Changed - Implemented new queue system +#### Dev/Meta +- Added unit testing framework + ## 3.0.1 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt index aa2783943..6faf49d22 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt @@ -74,7 +74,7 @@ class Date private constructor(private val tokens: List) : Comparable override fun hashCode() = tokens.hashCode() - override fun equals(other: Any?) = other is Date && other.compareTo(this) == 0 + override fun equals(other: Any?) = other is Date && compareTo(other) == 0 override fun compareTo(other: Date): Int { for (i in 0 until max(tokens.size, other.tokens.size)) { diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt index c0ae14f95..6a5fed7ea 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt @@ -18,63 +18,137 @@ package org.oxycblt.auxio.music import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class DateTest { - // TODO: Incomplete + @Test + fun date_equals() { + assertTrue( + requireNotNull(Date.from("2016-08-16T00:01:02")) == + requireNotNull(Date.from("2016-08-16T00:01:02"))) + } + + @Test + fun date_precisionEquals() { + assertTrue( + requireNotNull(Date.from("2016-08-16T00:01:02")) != + requireNotNull(Date.from("2016-08-16"))) + } + + @Test + fun date_compareDates() { + val a = requireNotNull(Date.from("2016-08-16T00:01:02")) + val b = requireNotNull(Date.from("2016-09-16T00:01:02")) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun date_compareTimes() { + val a = requireNotNull(Date.from("2016-08-16T00:02:02")) + val b = requireNotNull(Date.from("2016-08-16T00:01:02")) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun date_comparePrecision() { + val a = requireNotNull(Date.from("2016-08-16T00:01:02")) + val b = requireNotNull(Date.from("2016-08-16")) + assertEquals( + 1, + a.compareTo(b), + ) + } @Test fun date_fromYear() { - assertEquals(Date.from(2016).toString(), "2016") + assertEquals("2016", Date.from(2016).toString()) } @Test fun date_fromDate() { - assertEquals(Date.from(2016, 8, 16).toString(), "2016-08-16") + assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) } @Test fun date_fromDatetime() { - assertEquals(Date.from(2016, 8, 16, 0, 1).toString(), "2016-08-16T00:01Z") + assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) } @Test fun date_fromFormalTimestamp() { - assertEquals(Date.from("2016-08-16T00:01:02").toString(), "2016-08-16T00:01:02Z") + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16T00:01:02").toString()) } @Test fun date_fromSpacedTimestamp() { - assertEquals(Date.from("2016-08-16 00:01:02").toString(), "2016-08-16T00:01:02Z") + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16 00:01:02").toString()) } @Test fun date_fromDatestamp() { - assertEquals(Date.from("2016-08-16").toString(), "2016-08-16") + assertEquals( + "2016-08-16", + Date.from("2016-08-16").toString(), + ) } @Test fun date_fromWeirdDateTimestamp() { - assertEquals(Date.from("2016-08-16T00:01").toString(), "2016-08-16T00:01Z") + assertEquals("2016-08-16T00:01Z", Date.from("2016-08-16T00:01").toString()) } @Test fun date_fromWeirdDatestamp() { - assertEquals(Date.from("2016-08").toString(), "2016-08") + assertEquals("2016-08", Date.from("2016-08").toString()) } @Test fun date_fromYearStamp() { - assertEquals(Date.from("2016").toString(), "2016") + assertEquals("2016", Date.from("2016").toString()) } @Test fun date_fromWackTimestamp() { - assertEquals(Date.from("2016-11-32 25:43:01").toString(), "2016-11") + assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) + } + + @Test + fun date_fromBustedTimestamp() { + assertEquals(null, Date.from("2016-08-16:00:01:02")) + assertEquals(null, Date.from("")) } @Test fun date_fromWackYear() { assertEquals(Date.from(0), null) } + + @Test + fun dateRange_fromDates() { + val range = + requireNotNull( + Date.Range.from( + listOf( + requireNotNull(Date.from("2016-08-16T00:01:02")), + requireNotNull(Date.from("2016-07-16")), + requireNotNull(Date.from("2014-03-12T00")), + requireNotNull(Date.from("2022-12-22T22:22:22"))))) + assertEquals("2014-03-12T00Z", range.min.toString()) + assertEquals("2022-12-22T22:22:22Z", range.max.toString()) + } + + @Test + fun dateRange_fromSingle() { + val range = + requireNotNull( + Date.Range.from(listOf(requireNotNull(Date.from("2016-08-16T00:01:02"))))) + assertEquals("2016-08-16T00:01:02Z", range.min.toString()) + assertEquals("2016-08-16T00:01:02Z", range.max.toString()) + } + + @Test + fun dateRange_empty() { + assertEquals(null, Date.Range.from(listOf())) + } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt index dd9b0de79..8ff8e1491 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt @@ -21,63 +21,69 @@ import org.junit.Assert.assertEquals import org.junit.Test class ParsingUtilTest { + // TODO: Incomplete + @Test fun splitEscaped_correct() { - assertEquals("a,b,c".splitEscaped { it == ',' }, listOf("a", "b", "c")) + assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' }) } @Test fun splitEscaped_escaped() { - assertEquals("a\\,b,c".splitEscaped { it == ',' }, listOf("a,b", "c")) + assertEquals(listOf("a,b", "c"), "a\\,b,c".splitEscaped { it == ',' }) } @Test fun splitEscaped_whitespace() { - assertEquals("a , b, c , ".splitEscaped { it == ',' }, listOf("a ", " b", " c ", " ")) + assertEquals(listOf("a ", " b", " c ", " "), "a , b, c , ".splitEscaped { it == ',' }) } @Test fun splitEscaped_escapedWhitespace() { - assertEquals("a \\, b, c , ".splitEscaped { it == ',' }, listOf("a , b", " c ", " ")) + assertEquals(listOf("a , b", " c ", " "), ("a \\, b, c , ".splitEscaped { it == ',' })) } @Test fun correctWhitespace_stringCorrect() { assertEquals( - " asymptotic self-improvement ".correctWhitespace(), "asymptotic self-improvement") + "asymptotic self-improvement", + " asymptotic self-improvement ".correctWhitespace(), + ) } @Test fun correctWhitespace_stringOopsAllWhitespace() { - assertEquals(" ".correctWhitespace(), null) + assertEquals(null, "".correctWhitespace()) + assertEquals(null, " ".correctWhitespace()) } @Test fun correctWhitespace_listCorrect() { assertEquals( + listOf("asymptotic self-improvement", "daemons never stop", "tcp phagocyte"), listOf(" asymptotic self-improvement ", " daemons never stop", "tcp phagocyte") .correctWhitespace(), - listOf("asymptotic self-improvement", "daemons never stop", "tcp phagocyte")) + ) } @Test fun correctWhitespace_listOopsAllWhitespacE() { assertEquals( - listOf(" ", "", " tcp phagocyte").correctWhitespace(), listOf("tcp phagocyte")) + listOf("tcp phagocyte"), listOf(" ", "", " tcp phagocyte").correctWhitespace()) } @Test fun parseId3v2Position_correct() { - assertEquals("16/32".parseId3v2Position(), 16) + assertEquals(16, "16/32".parseId3v2Position()) } @Test fun parseId3v2Position_noTotal() { - assertEquals("16".parseId3v2Position(), 16) + assertEquals(16, "16".parseId3v2Position()) } @Test fun parseId3v2Position_wack() { - assertEquals("16/".parseId3v2Position(), 16) + assertEquals(16, "16/".parseId3v2Position()) } } From 502dd8ccc406d36977639768fa12f68fffdf033a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 12:56:46 -0700 Subject: [PATCH 12/55] actions: add testing step Add a testing command to the CI workflow. --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 448ac933e..edbb85883 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -29,6 +29,8 @@ jobs: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Test app with Gradle + run: ./gradlew app:test - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact From 782b570b388b9399e7f62b8a72f551aea2b869a8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 14:24:30 -0700 Subject: [PATCH 13/55] music: add texttags tests Add tests for the TextTags processing wrapper. --- .github/workflows/android.yml | 2 +- .../music/extractor/MetadataExtractor.kt | 6 +- .../music/extractor/{Tags.kt => TextTags.kt} | 8 +- .../replaygain/ReplayGainAudioProcessor.kt | 16 ++-- .../auxio/music/extractor/TextTagsTest.kt | 95 +++++++++++++++++++ 5 files changed, 113 insertions(+), 14 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/extractor/{Tags.kt => TextTags.kt} (91%) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index edbb85883..8a347a391 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -30,7 +30,7 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Test app with Gradle - run: ./gradlew app:test + run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact 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 d2047fabe..f9399e409 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 @@ -160,9 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) { val metadata = format.metadata if (metadata != null) { - val tags = Tags(metadata) - populateWithId3v2(tags.id3v2) - populateWithVorbis(tags.vorbis) + val textTags = TextTags(metadata) + populateWithId3v2(textTags.id3v2) + populateWithVorbis(textTags.vorbis) } else { logD("No metadata could be extracted for ${raw.name}") } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt rename to app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt index 03179a230..493a3421e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt @@ -24,11 +24,11 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment import org.oxycblt.auxio.music.parsing.correctWhitespace /** - * Processing wrapper for [Metadata] that allows access to more organized music tags. + * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. * @param metadata The [Metadata] to wrap. * @author Alexander Capehart (OxygenCobalt) */ -class Tags(metadata: Metadata) { +class TextTags(metadata: Metadata) { private val _id3v2 = mutableMapOf>() /** The ID3v2 text identification frames found in the file. Can have more than one value. */ val id3v2: Map> @@ -65,6 +65,10 @@ class Tags(metadata: Metadata) { is VorbisComment -> { // Vorbis comment keys can be in any case, make them uppercase for simplicity. val id = tag.key.sanitize().lowercase() + if (id == "metadata_block_picture") { + // Picture, we don't care about these + continue + } val value = tag.value.sanitize().correctWhitespace() if (value != null) { _vorbis.getOrPut(id) { mutableListOf() }.add(value) 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 ecd8a1eec..6c29f4e0b 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 @@ -30,7 +30,7 @@ import java.nio.ByteBuffer import kotlin.math.pow import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.extractor.Tags +import org.oxycblt.auxio.music.extractor.TextTags import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD @@ -166,23 +166,23 @@ class ReplayGainAudioProcessor(private val context: Context) : * @return A [Adjustment] adjustment, or null if there were no valid adjustments. */ private fun parseReplayGain(format: Format): Adjustment? { - val tags = Tags(format.metadata ?: return null) + val textTags = TextTags(format.metadata ?: return null) var trackGain = 0f var albumGain = 0f // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom // replaygain_*_gain tag. if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) { - tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] + textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it } - tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] + textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it } - tags.vorbis[TAG_RG_ALBUM_GAIN] + textTags.vorbis[TAG_RG_ALBUM_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it } - tags.vorbis[TAG_RG_TRACK_GAIN] + textTags.vorbis[TAG_RG_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it } } else { @@ -191,10 +191,10 @@ class ReplayGainAudioProcessor(private val context: Context) : // intrinsic to the format to create the normalized adjustment. That base adjustment // is already handled by the media framework, so we just need to apply the more // specific adjustments. - tags.vorbis[TAG_R128_TRACK_GAIN] + textTags.vorbis[TAG_R128_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it / 256f } - tags.vorbis[TAG_R128_ALBUM_GAIN] + textTags.vorbis[TAG_R128_ALBUM_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it / 256f } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt new file mode 100644 index 000000000..fbc6a1707 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/extractor/TextTagsTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.extractor + +import com.google.android.exoplayer2.metadata.Metadata +import com.google.android.exoplayer2.metadata.flac.PictureFrame +import com.google.android.exoplayer2.metadata.id3.ApicFrame +import com.google.android.exoplayer2.metadata.id3.InternalFrame +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame +import com.google.android.exoplayer2.metadata.vorbis.VorbisComment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TextTagsTest { + @Test + fun textTags_vorbis() { + val textTags = TextTags(VORBIS_METADATA) + assertTrue(textTags.id3v2.isEmpty()) + assertEquals(listOf("Wheel"), textTags.vorbis["title"]) + assertEquals(listOf("Paraglow"), textTags.vorbis["album"]) + assertEquals(listOf("Parannoul", "Asian Glow"), textTags.vorbis["artist"]) + assertEquals(listOf("2022"), textTags.vorbis["date"]) + assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) + assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + } + + @Test + fun textTags_id3v2() { + val textTags = TextTags(ID3V2_METADATA) + assertTrue(textTags.vorbis.isEmpty()) + assertEquals(listOf("Wheel"), textTags.id3v2["TIT2"]) + assertEquals(listOf("Paraglow"), textTags.id3v2["TALB"]) + assertEquals(listOf("Parannoul", "Asian Glow"), textTags.id3v2["TPE1"]) + assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) + assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) + assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) + } + + @Test + fun textTags_combined() { + val textTags = TextTags(VORBIS_METADATA.copyWithAppendedEntriesFrom(ID3V2_METADATA)) + assertEquals(listOf("Wheel"), textTags.vorbis["title"]) + assertEquals(listOf("Paraglow"), textTags.vorbis["album"]) + assertEquals(listOf("Parannoul", "Asian Glow"), textTags.vorbis["artist"]) + assertEquals(listOf("2022"), textTags.vorbis["date"]) + assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) + assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(listOf("Wheel"), textTags.id3v2["TIT2"]) + assertEquals(listOf("Paraglow"), textTags.id3v2["TALB"]) + assertEquals(listOf("Parannoul", "Asian Glow"), textTags.id3v2["TPE1"]) + assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) + assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) + assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) + } + + companion object { + private val VORBIS_METADATA = + Metadata( + VorbisComment("TITLE", "Wheel"), + VorbisComment("ALBUM", "Paraglow"), + VorbisComment("ARTIST", "Parannoul"), + VorbisComment("ARTIST", "Asian Glow"), + VorbisComment("DATE", "2022"), + VorbisComment("RELEASETYPE", "ep"), + VorbisComment("METADATA_BLOCK_PICTURE", ""), + VorbisComment("REPLAYGAIN_TRACK_GAIN", "+2 dB"), + PictureFrame(0, "", "", 0, 0, 0, 0, byteArrayOf())) + + private val ID3V2_METADATA = + Metadata( + TextInformationFrame("TIT2", null, listOf("Wheel")), + TextInformationFrame("TALB", null, listOf("Paraglow")), + TextInformationFrame("TPE1", null, listOf("Parannoul", "Asian Glow")), + TextInformationFrame("TDRC", null, listOf("2022")), + TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")), + InternalFrame("com.apple.iTunes", "replaygain_track_gain", "+2 dB"), + ApicFrame("", "", 0, byteArrayOf())) + } +} From a5ea4af5c41b473e67b4b5b862c10d39a8049bf0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 5 Jan 2023 20:08:12 -0700 Subject: [PATCH 14/55] music: finish parsing tests Finish ParsingUtil tests. --- .../java/org/oxycblt/auxio/music/Music.kt | 19 ++++--- .../music/extractor/MetadataExtractor.kt | 7 ++- .../auxio/music/parsing/ParsingUtil.kt | 50 ++++++----------- .../org/oxycblt/auxio/music/system/Indexer.kt | 3 +- .../org/oxycblt/auxio/settings/Settings.kt | 7 +-- .../auxio/music/parsing/ParsingUtilTest.kt | 56 ++++++++++++++++++- 6 files changed, 92 insertions(+), 50 deletions(-) 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 8d76972c5..ad24c9b64 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -381,9 +381,10 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { val album: Album get() = unlikelyToBeNull(_album) - private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings) - private val artistNames = raw.artistNames.parseMultiValue(settings) - private val artistSortNames = raw.artistSortNames.parseMultiValue(settings) + private val artistMusicBrainzIds = + raw.artistMusicBrainzIds.parseMultiValue(settings.musicSeparators) + private val artistNames = raw.artistNames.parseMultiValue(settings.musicSeparators) + private val artistSortNames = raw.artistSortNames.parseMultiValue(settings.musicSeparators) private val rawArtists = artistNames.mapIndexed { i, name -> Artist.Raw( @@ -392,9 +393,11 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { artistSortNames.getOrNull(i)) } - private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings) - private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings) - private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings) + private val albumArtistMusicBrainzIds = + raw.albumArtistMusicBrainzIds.parseMultiValue(settings.musicSeparators) + private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings.musicSeparators) + private val albumArtistSortNames = + raw.albumArtistSortNames.parseMultiValue(settings.musicSeparators) private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name -> Artist.Raw( @@ -462,7 +465,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, sortName = raw.albumSortName, - type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings)), + type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings.musicSeparators)), rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }) @@ -481,7 +484,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { */ val _rawGenres = raw.genreNames - .parseId3GenreNames(settings) + .parseId3GenreNames(settings.musicSeparators) .map { Genre.Raw(it) } .ifEmpty { listOf(Genre.Raw()) } 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 f9399e409..7e353cb91 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 @@ -21,6 +21,7 @@ import android.content.Context import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever +import kotlinx.coroutines.flow.flow import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position @@ -61,12 +62,12 @@ class MetadataExtractor( fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs) /** - * Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the - * sub-extractors before parsing the metadata itself. + * Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will + * first delegate to the sub-extractors before parsing the metadata itself. * @param emit A listener that will be invoked with every new [Song.Raw] instance when they are * successfully loaded. */ - suspend fun parse(emit: suspend (Song.Raw) -> Unit) { + fun extract() = flow { while (true) { val raw = Song.Raw() when (mediaStoreExtractor.populate(raw)) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt index 95f193971..fe7543062 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt @@ -17,7 +17,6 @@ package org.oxycblt.auxio.music.parsing -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.nonZeroOrNull /// --- GENERIC PARSING --- @@ -26,12 +25,12 @@ import org.oxycblt.auxio.util.nonZeroOrNull * Parse a multi-value tag based on the user configuration. If the value is already composed of more * than one value, nothing is done. Otherwise, this function will attempt to split it based on the * user's separator preferences. - * @param settings [Settings] required to obtain user separator configuration. + * @param separators A string of characters to split by. Can be empty. * @return A new list of one or more [String]s. */ -fun List.parseMultiValue(settings: Settings) = +fun List.parseMultiValue(separators: String) = if (size == 1) { - first().maybeParseBySeparators(settings) + first().maybeParseBySeparators(separators) } else { // Nothing to do. this @@ -83,7 +82,7 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List { /** * Fix trailing whitespace or blank contents in a [String]. - * @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or + * @return A string with trailing whitespace removed or null if the [String] was all whitespace or * empty. */ fun String.correctWhitespace() = trim().ifBlank { null } @@ -96,14 +95,15 @@ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } /** * Attempt to parse a string by the user's separator preferences. - * @param settings [Settings] required to obtain user separator configuration. - * @return A list of one or more [String]s that were split up by the user-defined separators. + * @param separators A string of characters to split by. Can be empty. + * @return A list of one or more [String]s that were split up by the given separators. */ -private fun String.maybeParseBySeparators(settings: Settings): List { - // Get the separators the user desires. If null, there's nothing to do. - val separators = settings.musicSeparators ?: return listOf(this) - return splitEscaped { separators.contains(it) }.correctWhitespace() -} +private fun String.maybeParseBySeparators(separators: String) = + if (separators.isNotEmpty()) { + splitEscaped { separators.contains(it) }.correctWhitespace() + } else { + listOf(this) + } /// --- ID3v2 PARSING --- @@ -119,29 +119,20 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * representations of genre fields into their named counterparts, and split up singular ID3v2-style * integer genre fields into one or more genres. - * @param settings [Settings] required to obtain user separator configuration. - * @return A list of one or more genre names.. + * @param separators A string of characters to split by. Can be empty. + * @return A list of one or more genre names. */ -fun List.parseId3GenreNames(settings: Settings) = +fun List.parseId3GenreNames(separators: String) = if (size == 1) { - first().parseId3MultiValueGenre(settings) + first().parseId3MultiValueGenre(separators) } else { // Nothing to split, just map any ID3v1 genres to their name counterparts. map { it.parseId3v1Genre() ?: it } } -/** - * Parse a single ID3v1/ID3v2 integer genre field into their named representations. - * @return A list of one or more genre names. - */ -private fun String.parseId3MultiValueGenre(settings: Settings) = - parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings) +private fun String.parseId3MultiValueGenre(separators: String) = + parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(separators) -/** - * Parse an ID3v1 integer genre field. - * @return A named genre if the field is a valid integer, "Cover" or "Remix" if the field is - * "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre. - */ private fun String.parseId3v1Genre(): String? { // ID3v1 genres are a plain integer value without formatting, so in that case // try to index the genre table with such. @@ -164,11 +155,6 @@ private fun String.parseId3v1Genre(): String? { */ private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") -/** - * Parse an ID3v2 integer genre field, which has support for multiple genre values and combined - * named/integer genres. - * @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre. - */ private fun String.parseId3v2Genre(): List? { val groups = (ID3V2_GENRE_RE.matchEntire(this) ?: return null).groupValues val genres = mutableSetOf() 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 1557ec4a8..adb25242a 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 @@ -24,6 +24,7 @@ import android.os.Build import androidx.core.content.ContextCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig @@ -264,7 +265,7 @@ class Indexer private constructor() { // Note: We use a set here so we can eliminate song duplicates. val songs = mutableSetOf() val rawSongs = mutableListOf() - metadataExtractor.parse { rawSong -> + metadataExtractor.extract().collect { rawSong -> songs.add(Song(rawSong, settings)) rawSongs.add(rawSong) 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 aabb07823..1ce8fac29 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -325,14 +325,13 @@ class Settings(private val context: Context) { * A string of characters representing the desired separator characters to denote multi-value * tags. */ - var musicSeparators: String? + var musicSeparators: String // Differ from convention and store a string of separator characters instead of an int // code. This makes it easier to use in Regexes and makes it more extendable. - get() = - inner.getString(context.getString(R.string.set_key_separators), null)?.ifEmpty { null } + get() = inner.getString(context.getString(R.string.set_key_separators), "") ?: "" set(value) { inner.edit { - putString(context.getString(R.string.set_key_separators), value?.ifEmpty { null }) + putString(context.getString(R.string.set_key_separators), value) apply() } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt index 8ff8e1491..64e7463d4 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt @@ -21,7 +21,21 @@ import org.junit.Assert.assertEquals import org.junit.Test class ParsingUtilTest { - // TODO: Incomplete + @Test + fun parseMultiValue_single() { + assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(",")) + } + + @Test + fun parseMultiValue_many() { + assertEquals(listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(",")) + } + + @Test + fun parseMultiValue_several() { + assertEquals( + listOf("a", "b", "c", "d", "e", "f"), listOf("a,b;c/d+e&f").parseMultiValue(",;/+&")) + } @Test fun splitEscaped_correct() { @@ -67,7 +81,7 @@ class ParsingUtilTest { } @Test - fun correctWhitespace_listOopsAllWhitespacE() { + fun correctWhitespace_listOopsAllWhitespace() { assertEquals( listOf("tcp phagocyte"), listOf(" ", "", " tcp phagocyte").correctWhitespace()) } @@ -86,4 +100,42 @@ class ParsingUtilTest { fun parseId3v2Position_wack() { assertEquals(16, "16/".parseId3v2Position()) } + + @Test + fun parseId3v2Genre_multi() { + assertEquals( + listOf("Post-Rock", "Shoegaze", "Glitch"), + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(",")) + } + + @Test + fun parseId3v2Genre_multiId3v1() { + assertEquals( + listOf("Post-Rock", "Shoegaze", "Glitch"), + listOf("176", "178", "Glitch").parseId3GenreNames(",")) + } + + @Test + fun parseId3v2Genre_wackId3() { + assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(",")) + } + + @Test + fun parseId3v2Genre_singleId3v23() { + assertEquals( + listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(",")) + } + + @Test + fun parseId3v2Genre_singleSeparated() { + assertEquals( + listOf("Post-Rock", "Shoegaze", "Glitch"), + listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(",")) + } + + @Test + fun parsId3v2Genre_singleId3v1() { + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(",")) + } } From a29875b5bfe7a06c5270d56fa0cf47aabc93aaa3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 6 Jan 2023 12:02:44 -0700 Subject: [PATCH 15/55] music: decouple library from musicstore/indexer De-couple the library data structure (and library grouping) from MusicStore and Indexer. This should make library creation *much* easier to test. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 10 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 5 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 10 +- .../list/selection/SelectionViewModel.kt | 2 +- .../java/org/oxycblt/auxio/music/Library.kt | 183 ++++++++++++++++++ .../org/oxycblt/auxio/music/MusicStore.kt | 103 +--------- .../auxio/music/picker/PickerViewModel.kt | 2 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 128 ++---------- .../playback/state/PlaybackStateDatabase.kt | 11 +- .../playback/state/PlaybackStateManager.kt | 6 +- .../auxio/playback/system/PlaybackService.kt | 3 +- .../oxycblt/auxio/search/SearchViewModel.kt | 9 +- 12 files changed, 218 insertions(+), 254 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/Library.kt 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 0ef6b3054..37545b526 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -32,13 +32,7 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item -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 +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* @@ -137,7 +131,7 @@ class DetailViewModel(application: Application) : musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library == null) { // Nothing to do. return 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 efa3d86f6..c88f25b8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -333,10 +333,7 @@ class HomeFragment : } } - private fun setupCompleteState( - binding: FragmentHomeBinding, - result: Result - ) { + private fun setupCompleteState(binding: FragmentHomeBinding, result: Result) { if (result.isSuccess) { logD("Received ok response") binding.homeFab.show() diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index f9e88e3ef..9661e3e2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -24,13 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -104,7 +98,7 @@ class HomeViewModel(application: Application) : settings.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { logD("Library changed, refreshing library") // Get the each list of items in the library to use as our list data. diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 754e8ca08..925d79bb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -38,7 +38,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener { musicStore.addListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library == null) { return } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/Library.kt new file mode 100644 index 000000000..6bbb91023 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Library.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD + +/** + * Organized music library information. + * + * This class allows for the creation of a well-formed music library graph from raw song + * information. It's generally not expected to create this yourself and instead use [MusicStore]. + * + * @author Alexander Capehart + */ +class Library(rawSongs: List, settings: Settings) { + /** All [Song]s that were detected on the device. */ + val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }) + /** All [Album]s found on the device. */ + val albums = buildAlbums(songs) + /** All [Artist]s found on the device. */ + val artists = buildArtists(songs, albums) + /** All [Genre]s found on the device. */ + val genres = buildGenres(songs) + + private val uidMap = buildMap { + // We need to finalize the newly-created music and also add it to a mapping to make + // de-serializing music from UIDs much faster. Do these in the same loop for efficiency. + for (music in (songs + albums + artists + genres)) { + music._finalize() + this[music.uid] = music + } + } + + /** + * Finds a [Music] item [T] in the library by it's [Music.UID]. + * @param uid The [Music.UID] to search for. + * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or + * the [Music.UID] did not correspond to a [T]. + */ + @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T + + /** + * Convert a [Song] from an another library into a [Song] in this [Library]. + * @param song The [Song] to convert. + * @return The analogous [Song] in this [Library], or null if it does not exist. + */ + fun sanitize(song: Song) = find(song.uid) + + /** + * Convert a [Album] from an another library into a [Album] in this [Library]. + * @param album The [Album] to convert. + * @return The analogous [Album] in this [Library], or null if it does not exist. + */ + fun sanitize(album: Album) = find(album.uid) + + /** + * Convert a [Artist] from an another library into a [Artist] in this [Library]. + * @param artist The [Artist] to convert. + * @return The analogous [Artist] in this [Library], or null if it does not exist. + */ + fun sanitize(artist: Artist) = find(artist.uid) + + /** + * Convert a [Genre] from an another library into a [Genre] in this [Library]. + * @param genre The [Genre] to convert. + * @return The analogous [Genre] in this [Library], or null if it does not exist. + */ + fun sanitize(genre: Genre) = find(genre.uid) + + /** + * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. + * @param context [Context] required to analyze the [Uri]. + * @param uri [Uri] to search for. + * @return A [Song] corresponding to the given [Uri], or null if one could not be found. + */ + fun findSongForUri(context: Context, uri: Uri) = + context.contentResolverSafe.useQuery( + uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> + cursor.moveToFirst() + // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a + // song. Do what we can to hopefully find the song the user wanted to open. + val displayName = + cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + songs.find { it.path.name == displayName && it.size == size } + } + + /** + * Build a list of [Album]s from the given [Song]s. + * @param songs The [Song]s to build [Album]s from. These will be linked with their respective + * [Album]s when created. + * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked + * with parent [Artist] instances in order to be usable. + */ + private fun buildAlbums(songs: List): List { + // Group songs by their singular raw album, then map the raw instances and their + // grouped songs to Album values. Album.Raw will handle the actual grouping rules. + val songsByAlbum = songs.groupBy { it._rawAlbum } + val albums = songsByAlbum.map { Album(it.key, it.value) } + logD("Successfully built ${albums.size} albums") + return albums + } + + /** + * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as + * they group into [Artist] instances much differently, with [Song]s being grouped primarily by + * artist names, and [Album]s being grouped primarily by album artist names. + * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of + * one or more [Artist] instances. These will be linked with their respective [Artist]s when + * created. + * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of + * one or more [Artist] instances. These will be linked with their respective [Artist]s when + * created. + * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings + * of [Song]s and [Album]s. + */ + private fun buildArtists(songs: List, albums: List): List { + // Add every raw artist credited to each Song/Album to the grouping. This way, + // different multi-artist combinations are not treated as different artists. + 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) + } + } + + // Convert the combined mapping into artist instances. + val artists = musicByArtist.map { Artist(it.key, it.value) } + logD("Successfully built ${artists.size} artists") + return artists + } + + /** + * Group up [Song]s into [Genre] instances. + * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of + * one or more [Genre] instances. These will be linked with their respective [Genre]s when + * created. + * @return A non-empty list of [Genre]s. + */ + private fun buildGenres(songs: List): List { + // Add every raw genre credited to each Song to the grouping. This way, + // different multi-genre combinations are not treated as different genres. + val songsByGenre = mutableMapOf>() + for (song in songs) { + for (rawGenre in song._rawGenres) { + songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) + } + } + + // Convert the mapping into genre instances. + val genres = songsByGenre.map { Genre(it.key, it.value) } + logD("Successfully built ${genres.size} genres") + return genres + } +} 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 f59356a3d..6b5ea25b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -17,14 +17,8 @@ package org.oxycblt.auxio.music -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import org.oxycblt.auxio.music.storage.contentResolverSafe -import org.oxycblt.auxio.music.storage.useQuery - /** - * A repository granting access to the music library.. + * A repository granting access to the music library. * * This can be used to obtain certain music items, or await changes to the music library. It is * generally recommended to use this over Indexer to keep track of the library state, as the @@ -72,101 +66,6 @@ class MusicStore private constructor() { listeners.remove(listener) } - /** - * A library of [Music] instances. - * @param songs All [Song]s loaded from the device. - * @param albums All [Album]s that could be created. - * @param artists All [Artist]s that could be created. - * @param genres All [Genre]s that could be created. - */ - data class Library( - val songs: List, - val albums: List, - val artists: List, - val genres: List, - ) { - private val uidMap = HashMap() - - init { - // The data passed to Library initially are complete, but are still volitaile. - // Finalize them to ensure they are well-formed. Also initialize the UID map in - // the same loop for efficiency. - for (song in songs) { - song._finalize() - uidMap[song.uid] = song - } - - for (album in albums) { - album._finalize() - uidMap[album.uid] = album - } - - for (artist in artists) { - artist._finalize() - uidMap[artist.uid] = artist - } - - for (genre in genres) { - genre._finalize() - uidMap[genre.uid] = genre - } - } - - /** - * Finds a [Music] item [T] in the library by it's [Music.UID]. - * @param uid The [Music.UID] to search for. - * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found - * or the [Music.UID] did not correspond to a [T]. - */ - @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T - - /** - * Convert a [Song] from an another library into a [Song] in this [Library]. - * @param song The [Song] to convert. - * @return The analogous [Song] in this [Library], or null if it does not exist. - */ - fun sanitize(song: Song) = find(song.uid) - - /** - * Convert a [Album] from an another library into a [Album] in this [Library]. - * @param album The [Album] to convert. - * @return The analogous [Album] in this [Library], or null if it does not exist. - */ - fun sanitize(album: Album) = find(album.uid) - - /** - * Convert a [Artist] from an another library into a [Artist] in this [Library]. - * @param artist The [Artist] to convert. - * @return The analogous [Artist] in this [Library], or null if it does not exist. - */ - fun sanitize(artist: Artist) = find(artist.uid) - - /** - * Convert a [Genre] from an another library into a [Genre] in this [Library]. - * @param genre The [Genre] to convert. - * @return The analogous [Genre] in this [Library], or null if it does not exist. - */ - fun sanitize(genre: Genre) = find(genre.uid) - - /** - * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. - * @param context [Context] required to analyze the [Uri]. - * @param uri [Uri] to search for. - * @return A [Song] corresponding to the given [Uri], or null if one could not be found. - */ - fun findSongForUri(context: Context, uri: Uri) = - context.contentResolverSafe.useQuery( - uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> - cursor.moveToFirst() - // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a - // song. Do what we can to hopefully find the song the user wanted to open. - val displayName = - cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) - val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) - songs.find { it.path.name == displayName && it.size == size } - } - } - /** A listener for changes in the music library. */ interface Listener { /** 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 0050a8bae..15e33b3ff 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 @@ -50,7 +50,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener { musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { refreshChoices() } 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 adb25242a..ac2af7502 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 @@ -24,17 +24,10 @@ import android.os.Build import androidx.core.content.ContextCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.withContext import kotlinx.coroutines.yield 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 +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.extractor.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD @@ -52,7 +45,7 @@ import org.oxycblt.auxio.util.logW * @author Alexander Capehart (OxygenCobalt) */ class Indexer private constructor() { - @Volatile private var lastResponse: Result? = null + @Volatile private var lastResponse: Result? = null @Volatile private var indexingState: Indexing? = null @Volatile private var controller: Controller? = null @Volatile private var listener: Listener? = null @@ -198,11 +191,11 @@ class Indexer private constructor() { * @param context [Context] required to load music. * @param withCache Whether to use the cache or not when loading. If false, the cache will still * be written, but no cache entries will be loaded into the new library. - * @return A newly-loaded [MusicStore.Library]. + * @return A newly-loaded [Library]. * @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted. * @throws NoMusicException If no music was found on the device. */ - private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library { + private suspend fun indexImpl(context: Context, withCache: Boolean): Library { if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { // No permissions, signal that we can't do anything. @@ -218,7 +211,6 @@ class Indexer private constructor() { } else { WriteOnlyCacheExtractor(context) } - val mediaStoreExtractor = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> @@ -227,33 +219,24 @@ class Indexer private constructor() { Api29MediaStoreExtractor(context, cacheDatabase) else -> Api21MediaStoreExtractor(context, cacheDatabase) } - val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor) - - val songs = - buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() } + val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() } // Build the rest of the music library from the song list. This is much more powerful // and reliable compared to using MediaStore to obtain grouping information. val buildStart = System.currentTimeMillis() - val albums = buildAlbums(songs) - val artists = buildArtists(songs, albums) - val genres = buildGenres(songs) + val library = Library(rawSongs, Settings(context)) logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") - return MusicStore.Library(songs, albums, artists, genres) + return library } /** * Load a list of [Song]s from the device. * @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw] * instances. - * @param settings [Settings] required to create [Song] instances. * @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked * with parent [Album], [Artist], and [Genre] items in order to be usable. */ - private suspend fun buildSongs( - metadataExtractor: MetadataExtractor, - settings: Settings - ): List { + private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List { logD("Starting indexing process") val start = System.currentTimeMillis() // Start initializing the extractors. Use an indeterminate state, as there is no ETA on @@ -263,104 +246,23 @@ class Indexer private constructor() { yield() // Note: We use a set here so we can eliminate song duplicates. - val songs = mutableSetOf() val rawSongs = mutableListOf() metadataExtractor.extract().collect { rawSong -> - songs.add(Song(rawSong, settings)) rawSongs.add(rawSong) - // Now we can signal a defined progress by showing how many songs we have // loaded, and the projected amount of songs we found in the library // (obtained by the extractors) yield() - emitIndexing(Indexing.Songs(songs.size, total)) + emitIndexing(Indexing.Songs(rawSongs.size, total)) } // Finalize the extractors with the songs we have now loaded. There is no ETA // on this process, so go back to an indeterminate state. emitIndexing(Indexing.Indeterminate) metadataExtractor.finalize(rawSongs) - logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") - - // Ensure that sorting order is consistent so that grouping is also consistent. - // Rolling this into the set is not an option, as songs with the same sort result - // would be lost. - return Sort(Sort.Mode.ByName, true).songs(songs) - } - - /** - * Build a list of [Album]s from the given [Song]s. - * @param songs The [Song]s to build [Album]s from. These will be linked with their respective - * [Album]s when created. - * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked - * with parent [Artist] instances in order to be usable. - */ - private fun buildAlbums(songs: List): List { - // Group songs by their singular raw album, then map the raw instances and their - // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val songsByAlbum = songs.groupBy { it._rawAlbum } - val albums = songsByAlbum.map { Album(it.key, it.value) } - logD("Successfully built ${albums.size} albums") - return albums - } - - /** - * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as - * they group into [Artist] instances much differently, with [Song]s being grouped primarily by - * artist names, and [Album]s being grouped primarily by album artist names. - * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings - * of [Song]s and [Album]s. - */ - private fun buildArtists(songs: List, albums: List): List { - // Add every raw artist credited to each Song/Album to the grouping. This way, - // different multi-artist combinations are not treated as different artists. - 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) - } - } - - // Convert the combined mapping into artist instances. - val artists = musicByArtist.map { Artist(it.key, it.value) } - logD("Successfully built ${artists.size} artists") - return artists - } - - /** - * Group up [Song]s into [Genre] instances. - * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of - * one or more [Genre] instances. These will be linked with their respective [Genre]s when - * created. - * @return A non-empty list of [Genre]s. - */ - private fun buildGenres(songs: List): List { - // Add every raw genre credited to each Song to the grouping. This way, - // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() - for (song in songs) { - for (rawGenre in song._rawGenres) { - songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) - } - } - - // Convert the mapping into genre instances. - val genres = songsByGenre.map { Genre(it.key, it.value) } - logD("Successfully built ${genres.size} genres") - return genres + logD( + "Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms") + return rawSongs } /** @@ -387,7 +289,7 @@ class Indexer private constructor() { * @param result The new [Result] to emit, representing the outcome of the music loading * process. */ - private suspend fun emitCompletion(result: Result) { + private suspend fun emitCompletion(result: Result) { yield() // Swap to the Main thread so that downstream callbacks don't crash from being on // a background thread. Does not occur in emitIndexing due to efficiency reasons. @@ -418,7 +320,7 @@ class Indexer private constructor() { * Music loading has completed. * @param result The outcome of the music loading process. */ - data class Complete(val result: Result) : State() + data class Complete(val result: Result) : State() } /** @@ -456,7 +358,7 @@ class Indexer private constructor() { * * This is only useful for code that absolutely must show the current loading process. * Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of - * the [MusicStore.Library]. + * the [Library]. */ interface Listener { /** 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 f63e5baf4..cb5f86284 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 @@ -23,10 +23,7 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns import androidx.core.database.sqlite.transaction -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.util.* /** @@ -72,10 +69,10 @@ class PlaybackStateDatabase private constructor(context: Context) : /** * Read a persisted [SavedState] from the database. - * @param library [MusicStore.Library] required to restore [SavedState]. + * @param library [Library] required to restore [SavedState]. * @return A persisted [SavedState], or null if one could not be found. */ - fun read(library: MusicStore.Library): SavedState? { + fun read(library: Library): SavedState? { requireBackgroundThread() // Read the saved state and queue. If the state is non-null, that must imply an // existent, albeit possibly empty, queue. @@ -123,7 +120,7 @@ class PlaybackStateDatabase private constructor(context: Context) : parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) } - private fun readQueue(library: MusicStore.Library): List { + private fun readQueue(library: Library): List { val queue = mutableListOf() readableDatabase.queryAll(TABLE_QUEUE) { cursor -> val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) 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 639fa6381..ce556e0a6 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 @@ -486,11 +486,11 @@ class PlaybackStateManager private constructor() { } /** - * Update the playback state to align with a new [MusicStore.Library]. - * @param newLibrary The new [MusicStore.Library] that was recently loaded. + * Update the playback state to align with a new [Library]. + * @param newLibrary The new [Library] that was recently loaded. */ @Synchronized - fun sanitize(newLibrary: MusicStore.Library) { + fun sanitize(newLibrary: Library) { // if (!isInitialized) { // // Nothing playing, nothing to do. // logD("Not initialized, no need to sanitize") 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 521bb5949..ece6fba6d 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 @@ -43,6 +43,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -302,7 +303,7 @@ class PlaybackService : // --- MUSICSTORE OVERRIDES --- - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 72ea04fae..5b57859c0 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -30,10 +30,7 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -63,7 +60,7 @@ class SearchViewModel(application: Application) : musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { // Make sure our query is up to date with the music library. search(lastQuery) @@ -96,7 +93,7 @@ class SearchViewModel(application: Application) : } } - private fun searchImpl(library: MusicStore.Library, query: String): List { + private fun searchImpl(library: Library, query: String): List { val sort = Sort(Sort.Mode.ByName, true) val filterMode = settings.searchFilterMode val results = mutableListOf() From 3502af33e7087e509b648aa0fef1fad9b39390de Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 6 Jan 2023 13:47:18 -0700 Subject: [PATCH 16/55] music: add support for date-encoding years Add support for date-encoding years such as "YYYYMMDD". This is a semi-common timestamp edge-case, it seems, primarily due to taggers wanting to encode date information in older tag formats. --- .../main/java/org/oxycblt/auxio/music/Date.kt | 26 ++++++++++++++----- .../music/extractor/MediaStoreExtractor.kt | 2 +- .../music/extractor/MetadataExtractor.kt | 2 +- .../java/org/oxycblt/auxio/music/DateTest.kt | 6 +++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/Date.kt index 6faf49d22..ad815a779 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Date.kt @@ -182,14 +182,25 @@ class Date private constructor(private val tokens: List) : Comparable */ private val ISO8601_REGEX = Regex( - """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") + """^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") /** * Create a [Date] from a year component. * @param year The year component. * @return A new [Date] of the given component, or null if the component is invalid. */ - fun from(year: Int) = fromTokens(listOf(year)) + fun from(year: Int) = + if (year in 10000000..100000000) { + // Year is actually more likely to be a separated date timestamp. Interpret + // it as such. + val stringYear = year.toString() + from( + stringYear.substring(0..3).toInt(), + stringYear.substring(4..5).toInt(), + stringYear.substring(6..7).toInt()) + } else { + fromTokens(listOf(year)) + } /** * Create a [Date] from a date component. @@ -222,8 +233,9 @@ class Date private constructor(private val tokens: List) : Comparable */ fun from(timestamp: String): Date? { val tokens = - // Match the input with the timestamp regex - (ISO8601_REGEX.matchEntire(timestamp) ?: return null) + // Match the input with the timestamp regex. If there is no match, see if we can + // fall back to some kind of year value. + (ISO8601_REGEX.matchEntire(timestamp) ?: return timestamp.toIntOrNull()?.let(::from)) .groupValues // Filter to the specific tokens we want and convert them to integer tokens. .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } @@ -238,7 +250,7 @@ class Date private constructor(private val tokens: List) : Comparable */ private fun fromTokens(tokens: List): Date? { val validated = mutableListOf() - validateTokens(tokens, validated) + transformTokens(tokens, validated) if (validated.isEmpty()) { // No token was valid, return null. return null @@ -252,7 +264,7 @@ class Date private constructor(private val tokens: List) : Comparable * @param src The input tokens to validate. * @param dst The destination list to add valid tokens to. */ - private fun validateTokens(src: List, dst: MutableList) { + private fun transformTokens(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) @@ -260,5 +272,7 @@ class Date private constructor(private val tokens: List) : Comparable dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) } + + private fun transformYearToken(src: List, dst: MutableList) {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 1ac7b082a..bc811f508 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -305,7 +305,7 @@ abstract class MediaStoreExtractor( // MediaStore only exposes the year value of a file. This is actually worse than it // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. // This is one of the major weaknesses of using MediaStore, hence the redundancy layers. - raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from) + raw.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) // A non-existent album name should theoretically be the name of the folder it contained // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the // file is not actually in the root internal storage directory. We can't do anything to 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 7e353cb91..4299a6d2b 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 @@ -293,7 +293,7 @@ class Task(context: Context, private val raw: Song.Raw) { // date tag that android supports, so it must be 15 years old or more!) (comments["originaldate"]?.run { Date.from(first()) } ?: comments["date"]?.run { Date.from(first()) } - ?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) }) + ?: comments["year"]?.run { Date.from(first()) }) ?.let { raw.date = it } // Album diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt index 6a5fed7ea..d88825eb1 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt @@ -124,6 +124,12 @@ class DateTest { assertEquals(Date.from(0), null) } + @Test + fun date_fromYearDate() { + assertEquals("2016", Date.from(2016).toString()) + assertEquals("2016", Date.from("2016").toString()) + } + @Test fun dateRange_fromDates() { val range = From 1b19b698a10403d55a6dd50187c159b36696fe1e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 6 Jan 2023 16:17:57 -0700 Subject: [PATCH 17/55] settings: decouple Decouple the settings god object into feature-specific settings. This should make testing settings-dependent code much easier, as it no longer requires a context. --- .../java/org/oxycblt/auxio/StubTest.kt | 9 +- .../main/java/org/oxycblt/auxio/AuxioApp.kt | 8 +- .../java/org/oxycblt/auxio/MainActivity.kt | 4 +- .../auxio/detail/AlbumDetailFragment.kt | 8 +- .../auxio/detail/ArtistDetailFragment.kt | 8 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 30 +- .../auxio/detail/GenreDetailFragment.kt | 8 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 2 + .../org/oxycblt/auxio/home/HomeSettings.kt | 64 +++ .../org/oxycblt/auxio/home/HomeViewModel.kt | 40 +- .../auxio/home/list/AlbumListFragment.kt | 1 + .../auxio/home/list/SongListFragment.kt | 4 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 6 +- .../org/oxycblt/auxio/image/ImageSettings.kt | 77 ++++ .../auxio/image/PlaybackIndicatorView.kt | 5 +- .../oxycblt/auxio/image/StyledImageView.kt | 4 +- .../oxycblt/auxio/image/extractor/Covers.kt | 6 +- .../list/selection/SelectionViewModel.kt | 2 + .../java/org/oxycblt/auxio/music/Library.kt | 4 +- .../java/org/oxycblt/auxio/music/Music.kt | 23 +- .../org/oxycblt/auxio/music/MusicSettings.kt | 213 +++++++++ .../music/extractor/MediaStoreExtractor.kt | 8 +- .../music/extractor/MetadataExtractor.kt | 2 +- .../auxio/music/parsing/ParsingUtil.kt | 50 +- .../auxio/music/parsing/SeparatorsDialog.kt | 8 +- .../auxio/music/picker/PickerViewModel.kt | 2 + .../auxio/music/storage/MusicDirsDialog.kt | 13 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 4 +- .../auxio/music/system/IndexerService.kt | 6 +- .../auxio/playback/PlaybackBarFragment.kt | 7 +- .../auxio/playback/PlaybackSettings.kt | 213 +++++++++ .../auxio/playback/PlaybackViewModel.kt | 34 +- .../replaygain/PreAmpCustomizeDialog.kt | 8 +- .../replaygain/ReplayGainAudioProcessor.kt | 4 +- .../playback/state/PlaybackStateDatabase.kt | 1 + .../playback/state/PlaybackStateManager.kt | 3 + .../playback/system/MediaSessionComponent.kt | 4 +- .../auxio/playback/system/PlaybackService.kt | 21 +- .../oxycblt/auxio/search/SearchFragment.kt | 4 +- .../oxycblt/auxio/search/SearchSettings.kt | 57 +++ .../oxycblt/auxio/search/SearchViewModel.kt | 6 +- .../org/oxycblt/auxio/settings/Settings.kt | 434 +----------------- .../settings/prefs/PreferenceFragment.kt | 6 +- .../java/org/oxycblt/auxio/ui/UISettings.kt | 100 ++++ .../auxio/ui/accent/AccentCustomizeDialog.kt | 6 +- .../oxycblt/auxio/widgets/WidgetComponent.kt | 4 +- .../oxycblt/auxio/widgets/WidgetProvider.kt | 5 +- .../org/oxycblt/auxio/widgets/WidgetUtil.kt | 10 + .../java/org/oxycblt/auxio/music/DateTest.kt | 4 +- .../oxycblt/auxio/music/FakeMusicSettings.kt | 54 +++ .../auxio/music/parsing/ParsingUtilTest.kt | 31 +- 51 files changed, 1038 insertions(+), 597 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt index 72626319e..7de0b5199 100644 --- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt +++ b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt @@ -18,7 +18,9 @@ package org.oxycblt.auxio import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.* +import org.junit.Test import org.junit.runner.RunWith /** @@ -28,5 +30,10 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class StubTest { - // TODO: Add tests + // TODO: Make tests + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.oxycblt.auxio", appContext.packageName) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 1e645d506..b2ff89af4 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -25,12 +25,14 @@ import androidx.core.graphics.drawable.IconCompat import coil.ImageLoader import coil.ImageLoaderFactory import coil.request.CachePolicy +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.ArtistImageFetcher import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory import org.oxycblt.auxio.image.extractor.GenreImageFetcher import org.oxycblt.auxio.image.extractor.MusicKeyer -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.ui.UISettings /** * Auxio: A simple, rational music player for android. @@ -40,7 +42,9 @@ class AuxioApp : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() // Migrate any settings that may have changed in an app update. - Settings(this).migrate() + ImageSettings.from(this).migrate() + PlaybackSettings.from(this).migrate() + UISettings.from(this).migrate() // Adding static shortcuts in a dynamic manner is better than declaring them // manually, as it will properly handle the difference between debug and release // Auxio instances. diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 25d40c62c..6b37b178d 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.system.PlaybackService -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.androidViewModels import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD @@ -81,7 +81,7 @@ class MainActivity : AppCompatActivity() { } private fun setupTheme() { - val settings = Settings(this) + val settings = UISettings.from(this) // Apply the theme configuration. AppCompatDelegate.setDefaultNightMode(settings.theme) // Apply the color scheme. The black theme requires it's own set of themes since 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 d94592b23..a69841ff0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -37,7 +37,7 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** @@ -123,7 +123,7 @@ class AlbumDetailFragment : override fun onRealClick(item: Music) { val song = requireIs(item) - when (Settings(requireContext()).detailPlaybackMode) { + when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { // "Play from shown item" and "Play from album" functionally have the same // behavior since a song can only have one album. null, @@ -149,12 +149,12 @@ class AlbumDetailFragment : override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_album_sort) { - val sort = detailModel.albumSort + val sort = detailModel.albumSortSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.albumSort = + detailModel.albumSortSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { 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 f87f9ab56..7fbe191a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -37,7 +37,7 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -123,7 +123,7 @@ class ArtistDetailFragment : ListFragment(), Detai override fun onRealClick(item: Music) { when (item) { is Song -> { - when (Settings(requireContext()).detailPlaybackMode) { + when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { // When configured to play from the selected item, we already have an Artist // to play from. null -> @@ -158,13 +158,13 @@ class ArtistDetailFragment : ListFragment(), Detai override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_artist_sort) { - val sort = detailModel.artistSort + val sort = detailModel.artistSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.artistSort = + detailModel.artistSongSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { 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 37545b526..4d5db2ec0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -33,8 +33,10 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.storage.MimeType -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* /** @@ -47,7 +49,7 @@ import org.oxycblt.auxio.util.* class DetailViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(application) + private val musicSettings = MusicSettings.from(application) private var currentSongJob: Job? = null @@ -75,10 +77,10 @@ class DetailViewModel(application: Application) : get() = _albumList /** The current [Sort] used for [Song]s in [albumList]. */ - var albumSort: Sort - get() = settings.detailAlbumSort + var albumSortSort: Sort + get() = musicSettings.albumSongSort set(value) { - settings.detailAlbumSort = value + musicSettings.albumSongSort = value // Refresh the album list to reflect the new sort. currentAlbum.value?.let(::refreshAlbumList) } @@ -95,10 +97,10 @@ class DetailViewModel(application: Application) : val artistList: StateFlow> = _artistList /** The current [Sort] used for [Song]s in [artistList]. */ - var artistSort: Sort - get() = settings.detailArtistSort + var artistSongSort: Sort + get() = musicSettings.artistSongSort set(value) { - settings.detailArtistSort = value + musicSettings.artistSongSort = value // Refresh the artist list to reflect the new sort. currentArtist.value?.let(::refreshArtistList) } @@ -115,10 +117,10 @@ class DetailViewModel(application: Application) : val genreList: StateFlow> = _genreList /** The current [Sort] used for [Song]s in [genreList]. */ - var genreSort: Sort - get() = settings.detailGenreSort + var genreSongSort: Sort + get() = musicSettings.genreSongSort set(value) { - settings.detailGenreSort = value + musicSettings.genreSongSort = value // Refresh the genre list to reflect the new sort. currentGenre.value?.let(::refreshGenreList) } @@ -309,7 +311,7 @@ class DetailViewModel(application: Application) : // To create a good user experience regarding disc numbers, we group the album's // songs up by disc and then delimit the groups by a disc header. - val songs = albumSort.songs(album.songs) + val songs = albumSortSort.songs(album.songs) // Songs without disc tags become part of Disc 1. val byDisc = songs.groupBy { it.disc ?: 1 } if (byDisc.size > 1) { @@ -363,7 +365,7 @@ class DetailViewModel(application: Application) : if (artist.songs.isNotEmpty()) { logD("Songs present in this artist, adding header") data.add(SortHeader(R.string.lbl_songs)) - data.addAll(artistSort.songs(artist.songs)) + data.addAll(artistSongSort.songs(artist.songs)) } _artistList.value = data.toList() @@ -376,7 +378,7 @@ class DetailViewModel(application: Application) : data.add(Header(R.string.lbl_artists)) data.addAll(genre.artists) data.add(SortHeader(R.string.lbl_songs)) - data.addAll(genreSort.songs(genre.songs)) + data.addAll(genreSongSort.songs(genre.songs)) _genreList.value = data } 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 302d3abfb..38568826e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -123,7 +123,7 @@ class GenreDetailFragment : ListFragment(), Detail when (item) { is Artist -> navModel.exploreNavigateTo(item) is Song -> - when (Settings(requireContext()).detailPlaybackMode) { + when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { // When configured to play from the selected item, we already have a Genre // to play from. null -> @@ -156,12 +156,12 @@ class GenreDetailFragment : ListFragment(), Detail override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_genre_sort) { - val sort = detailModel.genreSort + val sort = detailModel.genreSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.genreSort = + detailModel.genreSongSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { 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 c88f25b8e..8b89fef47 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -50,6 +50,8 @@ import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt new file mode 100644 index 000000000..e581a55a9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.home + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * User configuration specific to the home UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface HomeSettings : Settings { + /** The tabs to show in the home UI. */ + var homeTabs: Array + /** Whether to hide artists considered "collaborators" from the home UI. */ + val shouldHideCollaborators: Boolean + + private class Real(context: Context) : Settings.Real(context), HomeSettings { + override var homeTabs: Array + get() = + Tab.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_lib_tabs), Tab.SEQUENCE_DEFAULT)) + ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_lib_tabs), Tab.toIntCode(value)) + apply() + } + } + + override val shouldHideCollaborators: Boolean + get() = + sharedPreferences.getBoolean( + context.getString(R.string.set_key_hide_collaborators), false) + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): HomeSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 9661e3e2e..0283fc579 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -38,7 +40,8 @@ class HomeViewModel(application: Application) : MusicStore.Listener, SharedPreferences.OnSharedPreferenceChangeListener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(application) + private val homeSettings = HomeSettings.from(application) + private val musicSettings = MusicSettings.from(application) private val _songsList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -89,13 +92,13 @@ class HomeViewModel(application: Application) : init { musicStore.addListener(this) - settings.addListener(this) + homeSettings.addListener(this) } override fun onCleared() { super.onCleared() musicStore.removeListener(this) - settings.removeListener(this) + homeSettings.removeListener(this) } override fun onLibraryChanged(library: Library?) { @@ -103,17 +106,17 @@ class HomeViewModel(application: Application) : logD("Library changed, refreshing library") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. - _songsList.value = settings.libSongSort.songs(library.songs) - _albumsLists.value = settings.libAlbumSort.albums(library.albums) + _songsList.value = musicSettings.songSort.songs(library.songs) + _albumsLists.value = musicSettings.albumSort.albums(library.albums) _artistsList.value = - settings.libArtistSort.artists( - if (settings.shouldHideCollaborators) { + musicSettings.artistSort.artists( + if (homeSettings.shouldHideCollaborators) { // Hide Collaborators is enabled, filter out collaborators. library.artists.filter { !it.isCollaborator } } else { library.artists }) - _genresList.value = settings.libGenreSort.genres(library.genres) + _genresList.value = musicSettings.genreSort.genres(library.genres) } } @@ -156,10 +159,10 @@ class HomeViewModel(application: Application) : */ fun getSortForTab(tabMode: MusicMode) = when (tabMode) { - MusicMode.SONGS -> settings.libSongSort - MusicMode.ALBUMS -> settings.libAlbumSort - MusicMode.ARTISTS -> settings.libArtistSort - MusicMode.GENRES -> settings.libGenreSort + MusicMode.SONGS -> musicSettings.songSort + MusicMode.ALBUMS -> musicSettings.albumSort + MusicMode.ARTISTS -> musicSettings.artistSort + MusicMode.GENRES -> musicSettings.genreSort } /** @@ -171,19 +174,19 @@ class HomeViewModel(application: Application) : // Can simply re-sort the current list of items without having to access the library. when (_currentTabMode.value) { MusicMode.SONGS -> { - settings.libSongSort = sort + musicSettings.songSort = sort _songsList.value = sort.songs(_songsList.value) } MusicMode.ALBUMS -> { - settings.libAlbumSort = sort + musicSettings.albumSort = sort _albumsLists.value = sort.albums(_albumsLists.value) } MusicMode.ARTISTS -> { - settings.libArtistSort = sort + musicSettings.artistSort = sort _artistsList.value = sort.artists(_artistsList.value) } MusicMode.GENRES -> { - settings.libGenreSort = sort + musicSettings.genreSort = sort _genresList.value = sort.genres(_genresList.value) } } @@ -203,5 +206,6 @@ class HomeViewModel(application: Application) : * @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in * the same way as the configuration. */ - private fun makeTabModes() = settings.libTabs.filterIsInstance().map { it.mode } + private fun makeTabModes() = + homeSettings.homeTabs.filterIsInstance().map { it.mode } } 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 175ac2706..011ad304c 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 @@ -34,6 +34,7 @@ import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately 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 8b9db0d83..2ab60aad5 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 @@ -37,9 +37,9 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.collectImmediately /** @@ -130,7 +130,7 @@ class SongListFragment : } override fun onRealClick(item: Song) { - when (Settings(requireContext()).libPlaybackMode) { + when (PlaybackSettings.from(requireContext()).inListPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ARTISTS -> playbackModel.playFromArtist(item) diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index 393a4e182..e514413a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -25,8 +25,8 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding +import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD @@ -46,13 +46,13 @@ class TabCustomizeDialog : .setTitle(R.string.set_lib_tabs) .setPositiveButton(R.string.lbl_ok) { _, _ -> logD("Committing tab changes") - Settings(requireContext()).libTabs = tabAdapter.tabs + HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs } .setNegativeButton(R.string.lbl_cancel, null) } override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { - var tabs = Settings(requireContext()).libTabs + var tabs = HomeSettings.from(requireContext()).homeTabs // Try to restore a pending tab configuration that was saved prior. if (savedInstanceState != null) { val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS)) diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt new file mode 100644 index 000000000..cffa6df22 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD + +/** + * User configuration specific to image loading. + * @author Alexander Capehart (OxygenCobalt) + */ +interface ImageSettings : Settings { + /** The strategy to use when loading album covers. */ + val coverMode: CoverMode + + private class Real(context: Context) : Settings.Real(context), ImageSettings { + override val coverMode: CoverMode + get() = + CoverMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) + ?: CoverMode.MEDIA_STORE + + override fun migrate() { + // Show album covers and Ignore MediaStore covers were unified in 3.0.0 + if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) || + sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) { + logD("Migrating cover settings") + + val mode = + when { + !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF + !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> + CoverMode.MEDIA_STORE + else -> CoverMode.QUALITY + } + + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_cover_mode), mode.intCode) + remove(OLD_KEY_SHOW_COVERS) + remove(OLD_KEY_QUALITY_COVERS) + } + } + } + + private companion object { + const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" + const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): ImageSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt index f5df8f9bb..5da781bb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt @@ -28,7 +28,7 @@ import androidx.core.widget.ImageViewCompat import com.google.android.material.shape.MaterialShapeDrawable import kotlin.math.max import org.oxycblt.auxio.R -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat @@ -52,7 +52,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val indicatorMatrix = Matrix() private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() - private val settings = Settings(context) /** * The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius @@ -62,7 +61,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr set(value) { field = value (background as? MaterialShapeDrawable)?.let { bg -> - if (settings.roundMode) { + if (UISettings.from(context).roundMode) { bg.setCornerSize(value) } else { bg.setCornerSize(0f) diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index 590404a6a..d838a7b63 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat @@ -81,7 +81,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr background = MaterialShapeDrawable().apply { fillColor = context.getColorCompat(R.color.sel_cover_bg) - if (Settings(context).roundMode) { + if (UISettings.from(context).roundMode) { // Only use the specified corner radius when round mode is enabled. setCornerSize(cornerRadius) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt index 31a4bda55..b26141f7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt @@ -29,8 +29,8 @@ import java.io.InputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.image.CoverMode +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -47,10 +47,8 @@ object Covers { * loading failed or should not occur. */ suspend fun fetch(context: Context, album: Album): InputStream? { - val settings = Settings(context) - return try { - when (settings.coverMode) { + when (ImageSettings.from(context).coverMode) { CoverMode.OFF -> null CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album) CoverMode.QUALITY -> fetchQualityCovers(context, album) diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 925d79bb7..cb42d096e 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore /** * A [ViewModel] that manages the current selection. diff --git a/app/src/main/java/org/oxycblt/auxio/music/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/Library.kt index 6bbb91023..e85bc28d6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Library.kt @@ -20,9 +20,9 @@ package org.oxycblt.auxio.music import android.content.Context import android.net.Uri import android.provider.OpenableColumns +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.useQuery -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD /** @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart */ -class Library(rawSongs: List, settings: Settings) { +class Library(rawSongs: List, settings: MusicSettings) { /** All [Song]s that were detected on the device. */ val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }) /** All [Album]s found on the device. */ 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 ad24c9b64..3c8c031be 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.storage.* -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -308,10 +307,10 @@ sealed class MusicParent : Music() { /** * A song. Perhaps the foundation of the entirety of Auxio. * @param raw The [Song.Raw] to derive the member data from. - * @param settings [Settings] to determine the artist configuration. + * @param musicSettings [MusicSettings] to perform further user-configured parsing. * @author Alexander Capehart (OxygenCobalt) */ -class Song constructor(raw: Raw, settings: Settings) : Music() { +class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) } @@ -381,10 +380,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { val album: Album get() = unlikelyToBeNull(_album) - private val artistMusicBrainzIds = - raw.artistMusicBrainzIds.parseMultiValue(settings.musicSeparators) - private val artistNames = raw.artistNames.parseMultiValue(settings.musicSeparators) - private val artistSortNames = raw.artistSortNames.parseMultiValue(settings.musicSeparators) + private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings) + private val artistNames = raw.artistNames.parseMultiValue(musicSettings) + private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings) private val rawArtists = artistNames.mapIndexed { i, name -> Artist.Raw( @@ -394,10 +392,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { } private val albumArtistMusicBrainzIds = - raw.albumArtistMusicBrainzIds.parseMultiValue(settings.musicSeparators) - private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings.musicSeparators) - private val albumArtistSortNames = - raw.albumArtistSortNames.parseMultiValue(settings.musicSeparators) + raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) + private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings) + private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings) private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name -> Artist.Raw( @@ -465,7 +462,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, sortName = raw.albumSortName, - type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings.musicSeparators)), + type = Album.Type.parse(raw.albumTypes.parseMultiValue(musicSettings)), rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }) @@ -484,7 +481,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { */ val _rawGenres = raw.genreNames - .parseId3GenreNames(settings.musicSeparators) + .parseId3GenreNames(musicSettings) .map { Genre.Raw(it) } .ifEmpty { listOf(Genre.Raw()) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt new file mode 100644 index 000000000..6de1e1a63 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import android.content.Context +import android.os.storage.StorageManager +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.storage.MusicDirectories +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.getSystemServiceCompat + +/** + * User configuration specific to music system. + * @author Alexander Capehart (OxygenCobalt) + */ +interface MusicSettings : Settings { + /** The configuration on how to handle particular directories in the music library. */ + var musicDirs: MusicDirectories + /** Whether to exclude non-music audio files from the music library. */ + val excludeNonMusic: Boolean + /** Whether to be actively watching for changes in the music library. */ + val shouldBeObserving: Boolean + /** A [String] of characters representing the desired characters to denote multi-value tags. */ + var multiValueSeparators: String + /** The [Sort] mode used in [Song] lists. */ + var songSort: Sort + /** The [Sort] mode used in [Album] lists. */ + var albumSort: Sort + /** The [Sort] mode used in [Artist] lists. */ + var artistSort: Sort + /** The [Sort] mode used in [Genre] lists. */ + var genreSort: Sort + /** The [Sort] mode used in an [Album]'s [Song] list. */ + var albumSongSort: Sort + /** The [Sort] mode used in an [Artist]'s [Song] list. */ + var artistSongSort: Sort + /** The [Sort] mode used in an [Genre]'s [Song] list. */ + var genreSongSort: Sort + + private class Real(context: Context) : Settings.Real(context), MusicSettings { + private val storageManager = context.getSystemServiceCompat(StorageManager::class) + + override var musicDirs: MusicDirectories + get() { + val dirs = + (sharedPreferences.getStringSet( + context.getString(R.string.set_key_music_dirs), null) + ?: emptySet()) + .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } + return MusicDirectories( + dirs, + sharedPreferences.getBoolean( + context.getString(R.string.set_key_music_dirs_include), false)) + } + set(value) { + sharedPreferences.edit { + putStringSet( + context.getString(R.string.set_key_music_dirs), + value.dirs.map(Directory::toDocumentTreeUri).toSet()) + putBoolean( + context.getString(R.string.set_key_music_dirs_include), value.shouldInclude) + apply() + } + } + + override val excludeNonMusic: Boolean + get() = + sharedPreferences.getBoolean( + context.getString(R.string.set_key_exclude_non_music), true) + + override val shouldBeObserving: Boolean + get() = + sharedPreferences.getBoolean(context.getString(R.string.set_key_observing), false) + + override var multiValueSeparators: String + // Differ from convention and store a string of separator characters instead of an int + // code. This makes it easier to use and more extendable. + get() = + sharedPreferences.getString(context.getString(R.string.set_key_separators), "") + ?: "" + set(value) { + sharedPreferences.edit { + putString(context.getString(R.string.set_key_separators), value) + apply() + } + } + + override var songSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode) + apply() + } + } + + override var albumSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_lib_albums_sort), value.intCode) + apply() + } + } + + override var artistSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_lib_artists_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_lib_artists_sort), value.intCode) + apply() + } + } + + override var genreSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_lib_genres_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_lib_genres_sort), value.intCode) + apply() + } + } + + override var albumSongSort: Sort + get() { + var sort = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_detail_album_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDisc, true) + + // Correct legacy album sort modes to Disc + if (sort.mode is Sort.Mode.ByName) { + sort = sort.withMode(Sort.Mode.ByDisc) + } + + return sort + } + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_detail_album_sort), value.intCode) + apply() + } + } + + override var artistSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDate, false) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode) + apply() + } + } + + override var genreSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_detail_genre_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_detail_genre_sort), value.intCode) + apply() + } + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): MusicSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index bc811f508..62b983672 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -28,6 +28,7 @@ import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File import org.oxycblt.auxio.music.Date +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.music.storage.Directory @@ -37,7 +38,6 @@ import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.storage.safeQuery import org.oxycblt.auxio.music.storage.storageVolumesCompat import org.oxycblt.auxio.music.storage.useQuery -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull @@ -86,20 +86,20 @@ abstract class MediaStoreExtractor( open fun init(): Cursor { val start = System.currentTimeMillis() cacheExtractor.init() - val settings = Settings(context) + val musicSettings = MusicSettings.from(context) val storageManager = context.getSystemServiceCompat(StorageManager::class) val args = mutableListOf() var selector = BASE_SELECTOR // Filter out audio that is not music, if enabled. - if (settings.excludeNonMusic) { + if (musicSettings.excludeNonMusic) { logD("Excluding non-music") selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" } // Set up the projection to follow the music directory configuration. - val dirs = settings.getMusicDirs(storageManager) + val dirs = musicSettings.musicDirs if (dirs.dirs.isNotEmpty()) { selector += " AND " if (!dirs.shouldInclude) { 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 4299a6d2b..f4a203778 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 @@ -230,7 +230,7 @@ class Task(context: Context, private val raw: Song.Raw) { * Frames. * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more * values. - * @retrn A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a + * @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a * hour/minute value from TIME. No second value is included. The latter two fields may not be * included in they cannot be parsed. Will be null if a year value could not be parsed. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt index fe7543062..658b1cbea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio.music.parsing +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.util.nonZeroOrNull /// --- GENERIC PARSING --- @@ -25,12 +26,12 @@ import org.oxycblt.auxio.util.nonZeroOrNull * Parse a multi-value tag based on the user configuration. If the value is already composed of more * than one value, nothing is done. Otherwise, this function will attempt to split it based on the * user's separator preferences. - * @param separators A string of characters to split by. Can be empty. + * @param settings [MusicSettings] required to obtain user separator configuration. * @return A new list of one or more [String]s. */ -fun List.parseMultiValue(separators: String) = +fun List.parseMultiValue(settings: MusicSettings) = if (size == 1) { - first().maybeParseBySeparators(separators) + first().maybeParseBySeparators(settings) } else { // Nothing to do. this @@ -82,7 +83,7 @@ inline fun String.splitEscaped(selector: (Char) -> Boolean): List { /** * Fix trailing whitespace or blank contents in a [String]. - * @return A string with trailing whitespace removed or null if the [String] was all whitespace or + * @return A string with trailing whitespace remove,d or null if the [String] was all whitespace or * empty. */ fun String.correctWhitespace() = trim().ifBlank { null } @@ -95,15 +96,13 @@ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } /** * Attempt to parse a string by the user's separator preferences. - * @param separators A string of characters to split by. Can be empty. - * @return A list of one or more [String]s that were split up by the given separators. + * @param settings [Settings] required to obtain user separator configuration. + * @return A list of one or more [String]s that were split up by the user-defined separators. */ -private fun String.maybeParseBySeparators(separators: String) = - if (separators.isNotEmpty()) { - splitEscaped { separators.contains(it) }.correctWhitespace() - } else { - listOf(this) - } +private fun String.maybeParseBySeparators(settings: MusicSettings): List { + // Get the separators the user desires. If null, there's nothing to do. + return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace() +} /// --- ID3v2 PARSING --- @@ -119,20 +118,30 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * representations of genre fields into their named counterparts, and split up singular ID3v2-style * integer genre fields into one or more genres. - * @param separators A string of characters to split by. Can be empty. - * @return A list of one or more genre names. + * @param settings [MusicSettings] required to obtain user separator configuration. + * @return A list of one or more genre names.. */ -fun List.parseId3GenreNames(separators: String) = +fun List.parseId3GenreNames(settings: MusicSettings) = if (size == 1) { - first().parseId3MultiValueGenre(separators) + first().parseId3MultiValueGenre(settings) } else { // Nothing to split, just map any ID3v1 genres to their name counterparts. map { it.parseId3v1Genre() ?: it } } -private fun String.parseId3MultiValueGenre(separators: String) = - parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(separators) +/** + * Parse a single ID3v1/ID3v2 integer genre field into their named representations. + * @param settings [MusicSettings] required to obtain user separator configuration. + * @return A list of one or more genre names. + */ +private fun String.parseId3MultiValueGenre(settings: MusicSettings) = + parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings) +/** + * Parse an ID3v1 integer genre field. + * @return A named genre if the field is a valid integer, "Cover" or "Remix" if the field is + * "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre. + */ private fun String.parseId3v1Genre(): String? { // ID3v1 genres are a plain integer value without formatting, so in that case // try to index the genre table with such. @@ -155,6 +164,11 @@ private fun String.parseId3v1Genre(): String? { */ private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") +/** + * Parse an ID3v2 integer genre field, which has support for multiple genre values and combined + * named/integer genres. + * @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre. + */ private fun String.parseId3v2Genre(): List? { val groups = (ID3V2_GENRE_RE.matchEntire(this) ?: return null).groupValues val genres = mutableSetOf() diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt index de4802a25..6289ddc43 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt @@ -25,7 +25,7 @@ import com.google.android.material.checkbox.MaterialCheckBox import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment /** @@ -42,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_separators) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - Settings(requireContext()).musicSeparators = getCurrentSeparators() + MusicSettings.from(requireContext()).multiValueSeparators = getCurrentSeparators() } } @@ -59,8 +59,8 @@ class SeparatorsDialog : ViewBindingDialogFragment() { // the corresponding CheckBox for each character instead of doing an iteration // through the separator list for each CheckBox. (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) - ?: Settings(requireContext()).musicSeparators) - ?.forEach { + ?: MusicSettings.from(requireContext()).multiValueSeparators) + .forEach { when (it) { Separators.COMMA -> binding.separatorComma.isChecked = true Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true 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 15e33b3ff..3c6732f58 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 @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.util.unlikelyToBeNull /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index b16db8f79..e9cb75d42 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -30,7 +30,7 @@ import androidx.core.view.isVisible import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -56,14 +56,11 @@ class MusicDirsDialog : .setNeutralButton(R.string.lbl_add, null) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - val settings = Settings(requireContext()) - val dirs = - settings.getMusicDirs( - requireNotNull(storageManager) { "StorageManager was not available" }) + val settings = MusicSettings.from(requireContext()) val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding())) - if (dirs != newDirs) { + if (settings.musicDirs != newDirs) { logD("Committing changes") - settings.setMusicDirs(newDirs) + settings.musicDirs = newDirs } } } @@ -104,7 +101,7 @@ class MusicDirsDialog : itemAnimator = null } - var dirs = Settings(context).getMusicDirs(storageManager) + var dirs = MusicSettings.from(context).musicDirs if (savedInstanceState != null) { val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) if (pendingDirs != null) { 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 ac2af7502..826a77b3a 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 @@ -28,8 +28,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.extractor.* -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -224,7 +224,7 @@ class Indexer private constructor() { // Build the rest of the music library from the song list. This is much more powerful // and reliable compared to using MediaStore to obtain grouping information. val buildStart = System.currentTimeMillis() - val library = Library(rawSongs, Settings(context)) + val library = Library(rawSongs, MusicSettings.from(context)) logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") return library } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 9696b44e0..51bdd9465 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -33,11 +33,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -68,7 +68,7 @@ class IndexerService : private lateinit var observingNotification: ObservingNotification private lateinit var wakeLock: PowerManager.WakeLock private lateinit var indexerContentObserver: SystemContentObserver - private lateinit var settings: Settings + private lateinit var settings: MusicSettings override fun onCreate() { super.onCreate() @@ -83,7 +83,7 @@ class IndexerService : // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() - settings = Settings(this) + settings = MusicSettings.from(this) settings.addListener(this) indexer.registerController(this) // An indeterminate indexer and a missing library implies we are extremely early 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 af4ad3af3..1ca050570 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -24,7 +24,6 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -66,7 +65,7 @@ class PlaybackBarFragment : ViewBindingFragment() { // Set up actions binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } - setupSecondaryActions(binding, Settings(context)) + setupSecondaryActions(binding, PlaybackSettings.from(context).playbackBarAction) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources. @@ -86,8 +85,8 @@ class PlaybackBarFragment : ViewBindingFragment() { binding.playbackInfo.isSelected = false } - private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) { - when (settings.playbackBarAction) { + private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) { + when (actionMode) { ActionMode.NEXT -> { binding.playbackSecondaryAction.apply { setIconResource(R.drawable.ic_skip_next_24) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt new file mode 100644 index 000000000..8832de932 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.playback.replaygain.ReplayGainMode +import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD + +/** + * User configuration specific to the playback system. + * @author Alexander Capehart (OxygenCobalt) + */ +interface PlaybackSettings : Settings { + /** The action to display on the playback bar. */ + val playbackBarAction: ActionMode + /** The action to display in the playback notification. */ + val playbackNotificationAction: ActionMode + /** Whether to start playback when a headset is plugged in. */ + val headsetAutoplay: Boolean + /** The current ReplayGain configuration. */ + val replayGainMode: ReplayGainMode + /** The current ReplayGain pre-amp configuration. */ + var replayGainPreAmp: ReplayGainPreAmp + /** + * What type of MusicParent to play from when a Song is played from a list of other items. Null + * if to play from all Songs. + */ + val inListPlaybackMode: MusicMode + /** + * What type of MusicParent to play from when a Song is played from within an item (ex. like in + * the detail view). Null if to play from the item it was played in. + */ + val inParentPlaybackMode: MusicMode? + /** Whether to keep shuffle on when playing a new Song. */ + val keepShuffle: Boolean + /** Whether to rewind when the skip previous button is pressed before skipping back. */ + val rewindWithPrev: Boolean + /** Whether a song should pause after every repeat. */ + val pauseOnRepeat: Boolean + + private class Real(context: Context) : Settings.Real(context), PlaybackSettings { + override val inListPlaybackMode: MusicMode + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_library_song_playback_mode), + Int.MIN_VALUE)) + ?: MusicMode.SONGS + + override val inParentPlaybackMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_detail_song_playback_mode), + Int.MIN_VALUE)) + + override val playbackBarAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_bar_action), Int.MIN_VALUE)) + ?: ActionMode.NEXT + + override val playbackNotificationAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_notif_action), Int.MIN_VALUE)) + ?: ActionMode.REPEAT + + override val headsetAutoplay: Boolean + get() = + sharedPreferences.getBoolean( + context.getString(R.string.set_key_headset_autoplay), false) + + override val replayGainMode: ReplayGainMode + get() = + ReplayGainMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) + ?: ReplayGainMode.DYNAMIC + + override var replayGainPreAmp: ReplayGainPreAmp + get() = + ReplayGainPreAmp( + sharedPreferences.getFloat( + context.getString(R.string.set_key_pre_amp_with), 0f), + sharedPreferences.getFloat( + context.getString(R.string.set_key_pre_amp_without), 0f)) + set(value) { + sharedPreferences.edit { + putFloat(context.getString(R.string.set_key_pre_amp_with), value.with) + putFloat(context.getString(R.string.set_key_pre_amp_without), value.without) + apply() + } + } + + override val keepShuffle: Boolean + get() = + sharedPreferences.getBoolean(context.getString(R.string.set_key_keep_shuffle), true) + + override val rewindWithPrev: Boolean + get() = + sharedPreferences.getBoolean(context.getString(R.string.set_key_rewind_prev), true) + + override val pauseOnRepeat: Boolean + get() = + sharedPreferences.getBoolean( + context.getString(R.string.set_key_repeat_pause), false) + + override fun migrate() { + // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. + if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) { + logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION") + + val mode = + if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) { + ActionMode.SHUFFLE + } else { + ActionMode.REPEAT + } + + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_notif_action), mode.intCode) + remove(OLD_KEY_ALT_NOTIF_ACTION) + apply() + } + } + + // PlaybackMode was converted to MusicMode in 3.0.0 + + fun Int.migratePlaybackMode() = + when (this) { + // Convert PlaybackMode into MusicMode + IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS + IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS + IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS + IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES + else -> null + } + + if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE") + + val mode = + sharedPreferences + .getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) + .migratePlaybackMode() + ?: MusicMode.SONGS + + sharedPreferences.edit { + putInt( + context.getString(R.string.set_key_library_song_playback_mode), + mode.intCode) + remove(OLD_KEY_LIB_PLAYBACK_MODE) + apply() + } + } + + if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE") + + val mode = + sharedPreferences + .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE) + .migratePlaybackMode() + + sharedPreferences.edit { + putInt( + context.getString(R.string.set_key_detail_song_playback_mode), + mode?.intCode ?: Int.MIN_VALUE) + remove(OLD_KEY_DETAIL_PLAYBACK_MODE) + apply() + } + } + } + + companion object { + const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" + const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" + const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): PlaybackSettings = Real(context) + } +} 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 6a453726b..23f6e880d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -25,9 +25,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.music.* import org.oxycblt.auxio.playback.state.* -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.context /** @@ -36,7 +36,9 @@ import org.oxycblt.auxio.util.context */ class PlaybackViewModel(application: Application) : AndroidViewModel(application), PlaybackStateManager.Listener { - private val settings = Settings(application) + private val homeSettings = HomeSettings.from(application) + private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private val playbackManager = PlaybackStateManager.getInstance() private var lastPositionJob: Job? = null @@ -249,17 +251,17 @@ class PlaybackViewModel(application: Application) : private fun playImpl( song: Song?, parent: MusicParent?, - shuffled: Boolean = playbackManager.queue.isShuffled && settings.keepShuffle + shuffled: Boolean = playbackManager.queue.isShuffled && playbackSettings.keepShuffle ) { check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } val sort = when (parent) { - is Genre -> settings.detailGenreSort - is Artist -> settings.detailArtistSort - is Album -> settings.detailAlbumSort - null -> settings.libSongSort + is Genre -> musicSettings.genreSongSort + is Artist -> musicSettings.artistSongSort + is Album -> musicSettings.albumSongSort + null -> musicSettings.songSort } playbackManager.play(song, parent, sort, shuffled) } @@ -301,7 +303,7 @@ class PlaybackViewModel(application: Application) : * @param album The [Album] to add. */ fun playNext(album: Album) { - playbackManager.playNext(settings.detailAlbumSort.songs(album.songs)) + playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs)) } /** @@ -309,7 +311,7 @@ class PlaybackViewModel(application: Application) : * @param artist The [Artist] to add. */ fun playNext(artist: Artist) { - playbackManager.playNext(settings.detailArtistSort.songs(artist.songs)) + playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs)) } /** @@ -317,7 +319,7 @@ class PlaybackViewModel(application: Application) : * @param genre The [Genre] to add. */ fun playNext(genre: Genre) { - playbackManager.playNext(settings.detailGenreSort.songs(genre.songs)) + playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs)) } /** @@ -341,7 +343,7 @@ class PlaybackViewModel(application: Application) : * @param album The [Album] to add. */ fun addToQueue(album: Album) { - playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs)) + playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs)) } /** @@ -349,7 +351,7 @@ class PlaybackViewModel(application: Application) : * @param artist The [Artist] to add. */ fun addToQueue(artist: Artist) { - playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs)) + playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs)) } /** @@ -357,7 +359,7 @@ class PlaybackViewModel(application: Application) : * @param genre The [Genre] to add. */ fun addToQueue(genre: Genre) { - playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs)) + playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs)) } /** @@ -434,9 +436,9 @@ class PlaybackViewModel(application: Application) : private fun selectionToSongs(selection: List): List { return selection.flatMap { when (it) { - is Album -> settings.detailAlbumSort.songs(it.songs) - is Artist -> settings.detailArtistSort.songs(it.songs) - is Genre -> settings.detailGenreSort.songs(it.songs) + is Album -> musicSettings.albumSongSort.songs(it.songs) + is Artist -> musicSettings.artistSongSort.songs(it.songs) + is Genre -> musicSettings.genreSongSort.songs(it.songs) is Song -> listOf(it) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index 5cada49e2..fa589f015 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -24,7 +24,7 @@ import androidx.appcompat.app.AlertDialog import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment /** @@ -39,11 +39,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_pre_amp) .setPositiveButton(R.string.lbl_ok) { _, _ -> val binding = requireBinding() - Settings(requireContext()).replayGainPreAmp = + PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) } .setNeutralButton(R.string.lbl_reset) { _, _ -> - Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) + PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) } .setNegativeButton(R.string.lbl_cancel, null) } @@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { // First initialization, we need to supply the sliders with the values from // settings. After this, the sliders save their own state, so we do not need to // do any restore behavior. - val preAmp = Settings(requireContext()).replayGainPreAmp + val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp binding.withTagsSlider.value = preAmp.with binding.withoutTagsSlider.value = preAmp.without } 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 6c29f4e0b..8461eb6a1 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 @@ -31,8 +31,8 @@ import kotlin.math.pow import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.extractor.TextTags +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD /** @@ -48,7 +48,7 @@ import org.oxycblt.auxio.util.logD class ReplayGainAudioProcessor(private val context: Context) : BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val settings = PlaybackSettings.from(context) private var lastFormat: Format? = null private var volume = 1f 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 cb5f86284..b9ea4a594 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 @@ -24,6 +24,7 @@ import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns import androidx.core.database.sqlite.transaction import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.util.* /** 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 ce556e0a6..8a88c400e 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 @@ -21,6 +21,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE 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 b86aff5f9..c00b943b6 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 @@ -34,11 +34,11 @@ import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD /** @@ -59,7 +59,7 @@ class MediaSessionComponent(private val context: Context, private val listener: } private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val settings = PlaybackSettings.from(context) private val notification = NotificationComponent(context, mediaSession.sessionToken) private val provider = BitmapProvider(context) 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 ece6fba6d..790445ad3 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 @@ -44,15 +44,16 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateDatabase import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider @@ -92,7 +93,8 @@ class PlaybackService : // Managers private val playbackManager = PlaybackStateManager.getInstance() private val musicStore = MusicStore.getInstance() - private lateinit var settings: Settings + private lateinit var musicSettings: MusicSettings + private lateinit var playbackSettings: PlaybackSettings // State private lateinit var foregroundManager: ForegroundManager @@ -143,7 +145,8 @@ class PlaybackService : .also { it.addListener(this) } replayGainProcessor.addToListeners(player) // Initialize the core service components - settings = Settings(this) + musicSettings = MusicSettings.from(this) + playbackSettings = PlaybackSettings.from(this) foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. @@ -213,7 +216,7 @@ class PlaybackService : get() = player.audioSessionId override val shouldRewindWithPrev: Boolean - get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD + get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD override fun getState(durationMs: Long) = InternalPlayer.State.from( @@ -286,7 +289,7 @@ class PlaybackService : if (playbackManager.repeatMode == RepeatMode.TRACK) { playbackManager.rewind() // May be configured to pause when we repeat a track. - if (settings.pauseOnRepeat) { + if (playbackSettings.pauseOnRepeat) { playbackManager.setPlaying(false) } } else { @@ -352,7 +355,7 @@ class PlaybackService : } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, null, settings.libSongSort, true) + playbackManager.play(null, null, musicSettings.songSort, true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { @@ -360,8 +363,8 @@ class PlaybackService : playbackManager.play( song, null, - settings.libSongSort, - playbackManager.queue.isShuffled && settings.keepShuffle) + musicSettings.songSort, + playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } } } @@ -431,7 +434,7 @@ class PlaybackService : // ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached, // which would result in unexpected playback. Work around it by dropping the first // call to this function, which should come from that Intent. - if (settings.headsetAutoplay && + if (playbackSettings.headsetAutoplay && playbackManager.queue.currentSong != null && initialHeadsetPlugEventHandled) { logD("Device connected, resuming") 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 59f622cf5..48e037aaa 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** @@ -137,7 +137,7 @@ class SearchFragment : ListFragment() { override fun onRealClick(item: Music) { when (item) { is Song -> - when (Settings(requireContext()).libPlaybackMode) { + when (PlaybackSettings.from(requireContext()).inListPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(item) MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) MusicMode.ARTISTS -> playbackModel.playFromArtist(item) diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt new file mode 100644 index 000000000..05dac481a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.search + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.settings.Settings + +/** + * User configuration specific to the search UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface SearchSettings : Settings { + /** The type of Music the search view is currently filtering to. */ + var searchFilterMode: MusicMode? + + private class Real(context: Context) : Settings.Real(context), SearchSettings { + override var searchFilterMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + context.getString(R.string.set_key_search_filter), Int.MIN_VALUE)) + set(value) { + sharedPreferences.edit { + putInt( + context.getString(R.string.set_key_search_filter), + value?.intCode ?: Int.MIN_VALUE) + apply() + } + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): SearchSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 5b57859c0..1a4ea647d 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -31,7 +31,9 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -42,7 +44,7 @@ import org.oxycblt.auxio.util.logD class SearchViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(context) + private val settings = SearchSettings.from(application) private var lastQuery: String? = null private var currentSearchJob: Job? = null 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 1ce8fac29..f9a82fca7 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Auxio Project + * Copyright (c) 2023 Auxio Project * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,445 +19,49 @@ package org.oxycblt.auxio.settings import android.content.Context import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.os.Build -import android.os.storage.StorageManager -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.edit import androidx.preference.PreferenceManager -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.image.CoverMode -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.music.storage.Directory -import org.oxycblt.auxio.music.storage.MusicDirectories -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.replaygain.ReplayGainMode -import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp -import org.oxycblt.auxio.ui.accent.Accent -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member - * mutability is dependent on how they are used in app. Immutable members are often only modified by - * the preferences view, while mutable members are modified elsewhere. + * Abstract user configuration information. This interface has no functionality whatsoever. Concrete + * implementations should be preferred instead. * @author Alexander Capehart (OxygenCobalt) */ -class Settings(private val context: Context) { - private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - - /** - * Migrate any settings from an old version into their modern counterparts. This can cause data - * loss depending on the feasibility of a migration. - */ +interface Settings { + /** Migrate any settings fields from older versions into their new counterparts. */ fun migrate() { - if (inner.contains(OldKeys.KEY_ACCENT3)) { - logD("Migrating ${OldKeys.KEY_ACCENT3}") - - var accent = inner.getInt(OldKeys.KEY_ACCENT3, 5) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Accents were previously frozen as soon as the OS was updated to android twelve, - // as dynamic colors were enabled by default. This is no longer the case, so we need - // to re-update the setting to dynamic colors here. - accent = 16 - } - - inner.edit { - putInt(context.getString(R.string.set_key_accent), accent) - remove(OldKeys.KEY_ACCENT3) - apply() - } - } - - if (inner.contains(OldKeys.KEY_SHOW_COVERS) || inner.contains(OldKeys.KEY_QUALITY_COVERS)) { - logD("Migrating cover settings") - - val mode = - when { - !inner.getBoolean(OldKeys.KEY_SHOW_COVERS, true) -> CoverMode.OFF - !inner.getBoolean(OldKeys.KEY_QUALITY_COVERS, true) -> CoverMode.MEDIA_STORE - else -> CoverMode.QUALITY - } - - inner.edit { - putInt(context.getString(R.string.set_key_cover_mode), mode.intCode) - remove(OldKeys.KEY_SHOW_COVERS) - remove(OldKeys.KEY_QUALITY_COVERS) - } - } - - if (inner.contains(OldKeys.KEY_ALT_NOTIF_ACTION)) { - logD("Migrating ${OldKeys.KEY_ALT_NOTIF_ACTION}") - - val mode = - if (inner.getBoolean(OldKeys.KEY_ALT_NOTIF_ACTION, false)) { - ActionMode.SHUFFLE - } else { - ActionMode.REPEAT - } - - inner.edit { - putInt(context.getString(R.string.set_key_notif_action), mode.intCode) - remove(OldKeys.KEY_ALT_NOTIF_ACTION) - apply() - } - } - - fun Int.migratePlaybackMode() = - when (this) { - // Convert PlaybackMode into MusicMode - IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS - IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS - IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS - IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES - else -> null - } - - if (inner.contains(OldKeys.KEY_LIB_PLAYBACK_MODE)) { - logD("Migrating ${OldKeys.KEY_LIB_PLAYBACK_MODE}") - - val mode = - inner - .getInt(OldKeys.KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) - .migratePlaybackMode() - ?: MusicMode.SONGS - - inner.edit { - putInt(context.getString(R.string.set_key_library_song_playback_mode), mode.intCode) - remove(OldKeys.KEY_LIB_PLAYBACK_MODE) - apply() - } - } - - if (inner.contains(OldKeys.KEY_DETAIL_PLAYBACK_MODE)) { - logD("Migrating ${OldKeys.KEY_DETAIL_PLAYBACK_MODE}") - - val mode = - inner.getInt(OldKeys.KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE).migratePlaybackMode() - - inner.edit { - putInt( - context.getString(R.string.set_key_detail_song_playback_mode), - mode?.intCode ?: Int.MIN_VALUE) - remove(OldKeys.KEY_DETAIL_PLAYBACK_MODE) - apply() - } - } + throw NotImplementedError() } /** * Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates. * @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add. */ - fun addListener(listener: OnSharedPreferenceChangeListener) { - inner.registerOnSharedPreferenceChangeListener(listener) + fun addListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + throw NotImplementedError() } /** * Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further * settings updates from being sent to ti.t */ - fun removeListener(listener: OnSharedPreferenceChangeListener) { - inner.unregisterOnSharedPreferenceChangeListener(listener) - } - - // --- VALUES --- - - /** The current theme. Represented by the [AppCompatDelegate] constants. */ - val theme: Int - get() = - inner.getInt( - context.getString(R.string.set_key_theme), - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - - /** Whether to use a black background when a dark theme is currently used. */ - val useBlackTheme: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_black_theme), false) - - /** The current [Accent] (Color Scheme). */ - var accent: Accent - get() = - Accent.from(inner.getInt(context.getString(R.string.set_key_accent), Accent.DEFAULT)) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_accent), value.index) - apply() - } - } - - /** The tabs to show in the home UI. */ - var libTabs: Array - get() = - Tab.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_tabs), Tab.SEQUENCE_DEFAULT)) - ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_tabs), Tab.toIntCode(value)) - apply() - } - } - - /** Whether to hide artists considered "collaborators" from the home UI. */ - val shouldHideCollaborators: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_hide_collaborators), false) - - /** Whether to round additional UI elements that require album covers to be rounded. */ - val roundMode: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_round_mode), false) - - /** The action to display on the playback bar. */ - val playbackBarAction: ActionMode - get() = - ActionMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_bar_action), Int.MIN_VALUE)) - ?: ActionMode.NEXT - - /** The action to display in the playback notification. */ - val playbackNotificationAction: ActionMode - get() = - ActionMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_notif_action), Int.MIN_VALUE)) - ?: ActionMode.REPEAT - - /** Whether to start playback when a headset is plugged in. */ - val headsetAutoplay: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_headset_autoplay), false) - - /** The current ReplayGain configuration. */ - val replayGainMode: ReplayGainMode - get() = - ReplayGainMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) - ?: ReplayGainMode.DYNAMIC - - /** The current ReplayGain pre-amp configuration. */ - var replayGainPreAmp: ReplayGainPreAmp - get() = - ReplayGainPreAmp( - inner.getFloat(context.getString(R.string.set_key_pre_amp_with), 0f), - inner.getFloat(context.getString(R.string.set_key_pre_amp_without), 0f)) - set(value) { - inner.edit { - putFloat(context.getString(R.string.set_key_pre_amp_with), value.with) - putFloat(context.getString(R.string.set_key_pre_amp_without), value.without) - apply() - } - } - - /** What MusicParent item to play from when a Song is played from the home view. */ - val libPlaybackMode: MusicMode - get() = - MusicMode.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_library_song_playback_mode), Int.MIN_VALUE)) - ?: MusicMode.SONGS - - /** - * What MusicParent item to play from when a Song is played from the detail view. Will be null - * if configured to play from the currently shown item. - */ - val detailPlaybackMode: MusicMode? - get() = - MusicMode.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_detail_song_playback_mode), Int.MIN_VALUE)) - - /** Whether to keep shuffle on when playing a new Song. */ - val keepShuffle: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_keep_shuffle), true) - - /** Whether to rewind when the skip previous button is pressed before skipping back. */ - val rewindWithPrev: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_rewind_prev), true) - - /** Whether a song should pause after every repeat. */ - val pauseOnRepeat: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false) - - /** Whether to be actively watching for changes in the music library. */ - val shouldBeObserving: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_observing), false) - - /** The strategy used when loading album covers. */ - val coverMode: CoverMode - get() = - CoverMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) - ?: CoverMode.MEDIA_STORE - - /** Whether to exclude non-music audio files from the music library. */ - val excludeNonMusic: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_exclude_non_music), true) - - /** - * Set the configuration on how to handle particular directories in the music library. - * @param storageManager [StorageManager] required to parse directories. - * @return The [MusicDirectories] configuration. - */ - fun getMusicDirs(storageManager: StorageManager): MusicDirectories { - val dirs = - (inner.getStringSet(context.getString(R.string.set_key_music_dirs), null) ?: emptySet()) - .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } - return MusicDirectories( - dirs, inner.getBoolean(context.getString(R.string.set_key_music_dirs_include), false)) + fun removeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + throw NotImplementedError() } /** - * Set the configuration on how to handle particular directories in the music library. - * @param musicDirs The new [MusicDirectories] configuration. + * A framework-backed [Settings] implementation. + * @param context [Context] required. */ - fun setMusicDirs(musicDirs: MusicDirectories) { - inner.edit { - putStringSet( - context.getString(R.string.set_key_music_dirs), - musicDirs.dirs.map(Directory::toDocumentTreeUri).toSet()) - putBoolean( - context.getString(R.string.set_key_music_dirs_include), musicDirs.shouldInclude) - apply() - } - } + abstract class Real(protected val context: Context) : Settings { + protected val sharedPreferences: SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - /** - * A string of characters representing the desired separator characters to denote multi-value - * tags. - */ - var musicSeparators: String - // Differ from convention and store a string of separator characters instead of an int - // code. This makes it easier to use in Regexes and makes it more extendable. - get() = inner.getString(context.getString(R.string.set_key_separators), "") ?: "" - set(value) { - inner.edit { - putString(context.getString(R.string.set_key_separators), value) - apply() - } + override fun addListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) } - /** The type of Music the search view is currently filtering to. */ - var searchFilterMode: MusicMode? - get() = - MusicMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE)) - set(value) { - inner.edit { - putInt( - context.getString(R.string.set_key_search_filter), - value?.intCode ?: Int.MIN_VALUE) - apply() - } + override fun removeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } - - /** The Song [Sort] mode used in the Home UI. */ - var libSongSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode) - apply() - } - } - - /** The Album [Sort] mode used in the Home UI. */ - var libAlbumSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_albums_sort), value.intCode) - apply() - } - } - - /** The Artist [Sort] mode used in the Home UI. */ - var libArtistSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_artists_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_artists_sort), value.intCode) - apply() - } - } - - /** The Genre [Sort] mode used in the Home UI. */ - var libGenreSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_genres_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_genres_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Album Detail UI. */ - var detailAlbumSort: Sort - get() { - var sort = - Sort.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_detail_album_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDisc, true) - - // Correct legacy album sort modes to Disc - if (sort.mode is Sort.Mode.ByName) { - sort = sort.withMode(Sort.Mode.ByDisc) - } - - return sort - } - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_album_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Artist Detail UI. */ - var detailArtistSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDate, false) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Genre Detail UI. */ - var detailGenreSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_detail_genre_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_genre_sort), value.intCode) - apply() - } - } - - /** Legacy keys that are no longer used, but still have to be migrated. */ - private object OldKeys { - const val KEY_ACCENT3 = "auxio_accent" - const val KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" - const val KEY_SHOW_COVERS = "KEY_SHOW_COVERS" - const val KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" - const val KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" - const val KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt index 3eebc3176..cb7bdc82c 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt @@ -32,8 +32,8 @@ import coil.Coil import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.SettingsFragmentDirections +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD @@ -149,8 +149,6 @@ class PreferenceFragment : PreferenceFragmentCompat() { } private fun setupPreference(preference: Preference) { - val settings = Settings(requireContext()) - if (!preference.isVisible) { // Nothing to do. return @@ -170,7 +168,7 @@ class PreferenceFragment : PreferenceFragmentCompat() { } } getString(R.string.set_key_accent) -> { - preference.summary = getString(settings.accent.name) + preference.summary = getString(UISettings.from(requireContext()).accent.name) } getString(R.string.set_key_black_theme) -> { preference.onPreferenceChangeListener = diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt new file mode 100644 index 000000000..0faf91fbc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.ui + +import android.content.Context +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.accent.Accent +import org.oxycblt.auxio.util.logD + +interface UISettings : Settings { + /** The current theme. Represented by the AppCompatDelegate constants. */ + val theme: Int + /** Whether to use a black background when a dark theme is currently used. */ + val useBlackTheme: Boolean + /** The current [Accent] (Color Scheme). */ + var accent: Accent + /** Whether to round additional UI elements that require album covers to be rounded. */ + val roundMode: Boolean + + private class Real(context: Context) : Settings.Real(context), UISettings { + override val theme: Int + get() = + sharedPreferences.getInt( + context.getString(R.string.set_key_theme), + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + override val useBlackTheme: Boolean + get() = + sharedPreferences.getBoolean(context.getString(R.string.set_key_black_theme), false) + + override var accent: Accent + get() = + Accent.from( + sharedPreferences.getInt( + context.getString(R.string.set_key_accent), Accent.DEFAULT)) + set(value) { + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_accent), value.index) + apply() + } + } + + override val roundMode: Boolean + get() = + sharedPreferences.getBoolean(context.getString(R.string.set_key_round_mode), false) + + override fun migrate() { + if (sharedPreferences.contains(OLD_KEY_ACCENT3)) { + logD("Migrating $OLD_KEY_ACCENT3") + + var accent = sharedPreferences.getInt(OLD_KEY_ACCENT3, 5) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Accents were previously frozen as soon as the OS was updated to android + // twelve, + // as dynamic colors were enabled by default. This is no longer the case, so we + // need + // to re-update the setting to dynamic colors here. + accent = 16 + } + + sharedPreferences.edit { + putInt(context.getString(R.string.set_key_accent), accent) + remove(OLD_KEY_ACCENT3) + apply() + } + } + } + + companion object { + const val OLD_KEY_ACCENT3 = "auxio_accent" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): UISettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index f4d6237cc..c1ec29ad2 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -25,7 +25,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull @@ -44,7 +44,7 @@ class AccentCustomizeDialog : builder .setTitle(R.string.set_accent) .setPositiveButton(R.string.lbl_ok) { _, _ -> - val settings = Settings(requireContext()) + val settings = UISettings.from(requireContext()) if (accentAdapter.selectedAccent == settings.accent) { // Nothing to do. return@setPositiveButton @@ -65,7 +65,7 @@ class AccentCustomizeDialog : if (savedInstanceState != null) { Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT)) } else { - Settings(requireContext()).accent + UISettings.from(requireContext()).accent }) } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 6b11f4488..a4c62022d 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.logD @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logD class WidgetComponent(private val context: Context) : PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val settings = UISettings.from(context) private val widgetProvider = WidgetProvider() private val provider = BitmapProvider(context) diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 4c5259d9a..a333ec2f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -31,7 +31,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.system.PlaybackService -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* /** @@ -197,7 +196,7 @@ class WidgetProvider : AppWidgetProvider() { // Below API 31, enable a rounded bar only if round mode is enabled. // On API 31+, the bar should always be round in order to fit in with other widgets. val background = - if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (useRoundedRemoteViews(context)) { R.drawable.ui_widget_bar_round } else { R.drawable.ui_widget_bar_system @@ -216,7 +215,7 @@ class WidgetProvider : AppWidgetProvider() { // On API 31+, the background should always be round in order to fit in with other // widgets. val background = - if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (useRoundedRemoteViews(context)) { R.drawable.ui_widget_bg_round } else { R.drawable.ui_widget_bg_system diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 58f75b0a0..81b4dd6ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -27,6 +27,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.LayoutRes import kotlin.math.sqrt +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -132,3 +133,12 @@ fun AppWidgetManager.updateAppWidgetCompat( } } } + +/** + * Returns whether rounded UI elements are appropriate for the widget, either based on the current + * settings or if the widget has to fit in aesthetically with other widgets. + * @param context [Context] configuration to use. + * @return true if to use round mode, false otherwise. + */ +fun useRoundedRemoteViews(context: Context) = + UISettings.from(context).roundMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt index d88825eb1..721cfd629 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt @@ -126,8 +126,8 @@ class DateTest { @Test fun date_fromYearDate() { - assertEquals("2016", Date.from(2016).toString()) - assertEquals("2016", Date.from("2016").toString()) + assertEquals("2016-08-16", Date.from(20160816).toString()) + assertEquals("2016-08-16", Date.from("20160816").toString()) } @Test diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt new file mode 100644 index 000000000..795e7b29c --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import org.oxycblt.auxio.music.storage.MusicDirectories + +interface FakeMusicSettings : MusicSettings { + override var musicDirs: MusicDirectories + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override val excludeNonMusic: Boolean + get() = throw NotImplementedError() + override val shouldBeObserving: Boolean + get() = throw NotImplementedError() + override var multiValueSeparators: String + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var songSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var albumSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var artistSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var genreSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var albumSongSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var artistSongSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() + override var genreSongSort: Sort + get() = throw NotImplementedError() + set(_) = throw NotImplementedError() +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt index 64e7463d4..687653a14 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/parsing/ParsingUtilTest.kt @@ -19,22 +19,27 @@ package org.oxycblt.auxio.music.parsing import org.junit.Assert.assertEquals import org.junit.Test +import org.oxycblt.auxio.music.FakeMusicSettings class ParsingUtilTest { @Test fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(",")) + assertEquals( + listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(SeparatorMusicSettings(","))) } @Test fun parseMultiValue_many() { - assertEquals(listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(",")) + assertEquals( + listOf("a", "b", "c"), + listOf("a", "b", "c").parseMultiValue(SeparatorMusicSettings(","))) } @Test fun parseMultiValue_several() { assertEquals( - listOf("a", "b", "c", "d", "e", "f"), listOf("a,b;c/d+e&f").parseMultiValue(",;/+&")) + listOf("a", "b", "c", "d", "e", "f"), + listOf("a,b;c/d+e&f").parseMultiValue(SeparatorMusicSettings(",;/+&"))) } @Test @@ -105,37 +110,45 @@ class ParsingUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(",")) + listOf("Post-Rock", "Shoegaze", "Glitch") + .parseId3GenreNames(SeparatorMusicSettings(","))) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(",")) + listOf("176", "178", "Glitch").parseId3GenreNames(SeparatorMusicSettings(","))) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(",")) + assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(SeparatorMusicSettings(","))) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(",")) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(SeparatorMusicSettings(","))) } @Test fun parseId3v2Genre_singleSeparated() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(",")) + listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(SeparatorMusicSettings(","))) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(",")) + assertEquals( + listOf("Post-Rock"), listOf("176").parseId3GenreNames(SeparatorMusicSettings(","))) + } + + class SeparatorMusicSettings(private val separators: String) : FakeMusicSettings { + override var multiValueSeparators: String + get() = separators + set(_) = throw NotImplementedError() } } From ac9f50c0a0e29a21d1b9c31a1b51a198dfa00303 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 6 Jan 2023 19:20:56 -0700 Subject: [PATCH 18/55] settings: do not use sharedpreference listener Switch back to using settings-specific listeners rather than the SharedPreference listener. Again, this is due to the need to decouple android code from settings. It also allows us to fully obscure the details of what settings we are actually working with. --- CHANGELOG.md | 2 + .../org/oxycblt/auxio/home/HomeSettings.kt | 26 +++++-- .../org/oxycblt/auxio/home/HomeViewModel.kt | 34 ++++----- .../org/oxycblt/auxio/image/ImageSettings.kt | 20 ++++-- .../org/oxycblt/auxio/music/MusicSettings.kt | 72 +++++++++++-------- .../auxio/music/system/IndexerService.kt | 37 ++++------ .../auxio/playback/PlaybackBarFragment.kt | 2 +- .../auxio/playback/PlaybackSettings.kt | 71 +++++++++--------- .../replaygain/ReplayGainAudioProcessor.kt | 26 +++---- .../playback/state/PlaybackStateManager.kt | 4 +- .../playback/system/MediaSessionComponent.kt | 28 ++++---- .../oxycblt/auxio/search/SearchSettings.kt | 9 ++- .../org/oxycblt/auxio/settings/Settings.kt | 60 +++++++++++----- .../settings/prefs/PreferenceFragment.kt | 2 +- .../java/org/oxycblt/auxio/ui/UISettings.kt | 35 +++++---- .../oxycblt/auxio/widgets/WidgetComponent.kt | 26 ++++--- app/src/main/res/values/settings.xml | 20 +++--- app/src/main/res/xml/prefs_main.xml | 6 +- .../oxycblt/auxio/music/FakeMusicSettings.kt | 2 + 19 files changed, 270 insertions(+), 212 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbbf847e..d974ead12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,13 @@ #### What's Improved - Added ability to edit previously played or currently playing items in the queue +- Added support for date values formatted as "YYYYMMDD" #### What's Fixed - Fixed unreliable ReplayGain adjustment application in certain situations - Fixed crash that would occur in music folders dialog when user does not have a working file manager +- Fixed notification not updating due to settings changes #### What's Changed - Implemented new queue system diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index e581a55a9..c36e9d838 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -28,30 +28,44 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * User configuration specific to the home UI. * @author Alexander Capehart (OxygenCobalt) */ -interface HomeSettings : Settings { +interface HomeSettings : Settings { /** The tabs to show in the home UI. */ var homeTabs: Array /** Whether to hide artists considered "collaborators" from the home UI. */ val shouldHideCollaborators: Boolean - private class Real(context: Context) : Settings.Real(context), HomeSettings { + interface Listener { + /** Called when the [homeTabs] configuration changes. */ + fun onTabsChanged() + /** Called when the [shouldHideCollaborators] configuration changes. */ + fun onHideCollaboratorsChanged() + } + + private class Real(context: Context) : Settings.Real(context), HomeSettings { override var homeTabs: Array get() = Tab.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_lib_tabs), Tab.SEQUENCE_DEFAULT)) + getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT)) ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_lib_tabs), Tab.toIntCode(value)) + putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value)) apply() } } override val shouldHideCollaborators: Boolean get() = - sharedPreferences.getBoolean( - context.getString(R.string.set_key_hide_collaborators), false) + sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false) + + override fun onSettingChanged(key: String, listener: Listener) { + when (key) { + getString(R.string.set_key_home_tabs) -> listener.onTabsChanged() + getString(R.string.set_key_hide_collaborators) -> + listener.onHideCollaboratorsChanged() + } + } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 0283fc579..2798f64f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -18,17 +18,14 @@ package org.oxycblt.auxio.home import android.app.Application -import android.content.SharedPreferences import androidx.lifecycle.AndroidViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD /** @@ -36,9 +33,7 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class HomeViewModel(application: Application) : - AndroidViewModel(application), - MusicStore.Listener, - SharedPreferences.OnSharedPreferenceChangeListener { + AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener { private val musicStore = MusicStore.getInstance() private val homeSettings = HomeSettings.from(application) private val musicSettings = MusicSettings.from(application) @@ -92,13 +87,13 @@ class HomeViewModel(application: Application) : init { musicStore.addListener(this) - homeSettings.addListener(this) + homeSettings.registerListener(this) } override fun onCleared() { super.onCleared() musicStore.removeListener(this) - homeSettings.removeListener(this) + homeSettings.unregisterListener(this) } override fun onLibraryChanged(library: Library?) { @@ -120,19 +115,16 @@ class HomeViewModel(application: Application) : } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - context.getString(R.string.set_key_lib_tabs) -> { - // Tabs changed, update the current tabs and set up a re-create event. - currentTabModes = makeTabModes() - _shouldRecreate.value = true - } - context.getString(R.string.set_key_hide_collaborators) -> { - // Changes in the hide collaborator setting will change the artist contents - // of the library, consider it a library update. - onLibraryChanged(musicStore.library) - } - } + override fun onTabsChanged() { + // Tabs changed, update the current tabs and set up a re-create event. + currentTabModes = makeTabModes() + _shouldRecreate.value = true + } + + override fun onHideCollaboratorsChanged() { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + onLibraryChanged(musicStore.library) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt index cffa6df22..d5dd32dd1 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -27,16 +27,20 @@ import org.oxycblt.auxio.util.logD * User configuration specific to image loading. * @author Alexander Capehart (OxygenCobalt) */ -interface ImageSettings : Settings { +interface ImageSettings : Settings { /** The strategy to use when loading album covers. */ val coverMode: CoverMode - private class Real(context: Context) : Settings.Real(context), ImageSettings { + interface Listener { + /** Called when [coverMode] changes. */ + fun onCoverModeChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), ImageSettings { override val coverMode: CoverMode get() = CoverMode.fromIntCode( - sharedPreferences.getInt( - context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) + sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) ?: CoverMode.MEDIA_STORE override fun migrate() { @@ -54,13 +58,19 @@ interface ImageSettings : Settings { } sharedPreferences.edit { - putInt(context.getString(R.string.set_key_cover_mode), mode.intCode) + putInt(getString(R.string.set_key_cover_mode), mode.intCode) remove(OLD_KEY_SHOW_COVERS) remove(OLD_KEY_QUALITY_COVERS) } } } + override fun onSettingChanged(key: String, listener: Listener) { + if (key == getString(R.string.set_key_cover_mode)) { + listOf(key, listener) + } + } + private companion object { const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 6de1e1a63..465f7c8df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.getSystemServiceCompat * User configuration specific to music system. * @author Alexander Capehart (OxygenCobalt) */ -interface MusicSettings : Settings { +interface MusicSettings : Settings { /** The configuration on how to handle particular directories in the music library. */ var musicDirs: MusicDirectories /** Whether to exclude non-music audio files from the music library. */ @@ -54,50 +54,51 @@ interface MusicSettings : Settings { /** The [Sort] mode used in an [Genre]'s [Song] list. */ var genreSongSort: Sort - private class Real(context: Context) : Settings.Real(context), MusicSettings { + interface Listener { + /** Called when a setting controlling how music is loaded has changed. */ + fun onIndexingSettingChanged() {} + /** Called when the [shouldBeObserving] configuration has changed. */ + fun onObservingChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) override var musicDirs: MusicDirectories get() { val dirs = - (sharedPreferences.getStringSet( - context.getString(R.string.set_key_music_dirs), null) + (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) ?: emptySet()) .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } return MusicDirectories( dirs, sharedPreferences.getBoolean( - context.getString(R.string.set_key_music_dirs_include), false)) + getString(R.string.set_key_music_dirs_include), false)) } set(value) { sharedPreferences.edit { putStringSet( - context.getString(R.string.set_key_music_dirs), + getString(R.string.set_key_music_dirs), value.dirs.map(Directory::toDocumentTreeUri).toSet()) - putBoolean( - context.getString(R.string.set_key_music_dirs_include), value.shouldInclude) + putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) apply() } } override val excludeNonMusic: Boolean get() = - sharedPreferences.getBoolean( - context.getString(R.string.set_key_exclude_non_music), true) + sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) override val shouldBeObserving: Boolean - get() = - sharedPreferences.getBoolean(context.getString(R.string.set_key_observing), false) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) override var multiValueSeparators: String // Differ from convention and store a string of separator characters instead of an int // code. This makes it easier to use and more extendable. - get() = - sharedPreferences.getString(context.getString(R.string.set_key_separators), "") - ?: "" + get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: "" set(value) { sharedPreferences.edit { - putString(context.getString(R.string.set_key_separators), value) + putString(getString(R.string.set_key_separators), value) apply() } } @@ -105,12 +106,11 @@ interface MusicSettings : Settings { override var songSort: Sort get() = Sort.fromIntCode( - sharedPreferences.getInt( - context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE)) + sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByName, true) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode) + putInt(getString(R.string.set_key_songs_sort), value.intCode) apply() } } @@ -119,11 +119,11 @@ interface MusicSettings : Settings { get() = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE)) + getString(R.string.set_key_albums_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByName, true) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_lib_albums_sort), value.intCode) + putInt(getString(R.string.set_key_albums_sort), value.intCode) apply() } } @@ -132,11 +132,11 @@ interface MusicSettings : Settings { get() = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_lib_artists_sort), Int.MIN_VALUE)) + getString(R.string.set_key_artists_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByName, true) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_lib_artists_sort), value.intCode) + putInt(getString(R.string.set_key_artists_sort), value.intCode) apply() } } @@ -145,11 +145,11 @@ interface MusicSettings : Settings { get() = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_lib_genres_sort), Int.MIN_VALUE)) + getString(R.string.set_key_genres_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByName, true) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_lib_genres_sort), value.intCode) + putInt(getString(R.string.set_key_genres_sort), value.intCode) apply() } } @@ -159,7 +159,7 @@ interface MusicSettings : Settings { var sort = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_detail_album_sort), Int.MIN_VALUE)) + getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByDisc, true) // Correct legacy album sort modes to Disc @@ -171,7 +171,7 @@ interface MusicSettings : Settings { } set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_detail_album_sort), value.intCode) + putInt(getString(R.string.set_key_album_songs_sort), value.intCode) apply() } } @@ -180,11 +180,11 @@ interface MusicSettings : Settings { get() = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)) + getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByDate, false) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode) + putInt(getString(R.string.set_key_artist_songs_sort), value.intCode) apply() } } @@ -193,14 +193,24 @@ interface MusicSettings : Settings { get() = Sort.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_detail_genre_sort), Int.MIN_VALUE)) + getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE)) ?: Sort(Sort.Mode.ByName, true) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_detail_genre_sort), value.intCode) + putInt(getString(R.string.set_key_genre_songs_sort), value.intCode) apply() } } + + override fun onSettingChanged(key: String, listener: Listener) { + when (key) { + getString(R.string.set_key_exclude_non_music), + getString(R.string.set_key_music_dirs), + getString(R.string.set_key_music_dirs_include), + getString(R.string.set_key_separators) -> listener.onIndexingSettingChanged() + getString(R.string.set_key_observing) -> listener.onObservingChanged() + } + } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 51bdd9465..6358cb9ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.system import android.app.Service import android.content.Intent -import android.content.SharedPreferences import android.database.ContentObserver import android.os.Handler import android.os.IBinder @@ -32,7 +31,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.storage.contentResolverSafe @@ -55,8 +53,7 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class IndexerService : - Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener { +class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { private val indexer = Indexer.getInstance() private val musicStore = MusicStore.getInstance() private val playbackManager = PlaybackStateManager.getInstance() @@ -84,7 +81,7 @@ class IndexerService : // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() settings = MusicSettings.from(this) - settings.addListener(this) + settings.registerListener(this) indexer.registerController(this) // An indeterminate indexer and a missing library implies we are extremely early // in app initialization so start loading music. @@ -108,7 +105,7 @@ class IndexerService : // Then cancel the listener-dependent components to ensure that stray reloading // events will not occur. indexerContentObserver.release() - settings.removeListener(this) + settings.unregisterListener(this) indexer.unregisterController(this) // Then cancel any remaining music loading jobs. serviceJob.cancel() @@ -230,22 +227,18 @@ class IndexerService : // --- SETTING CALLBACKS --- - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - // Hook changes in music settings to a new music loading event. - getString(R.string.set_key_exclude_non_music), - getString(R.string.set_key_music_dirs), - getString(R.string.set_key_music_dirs_include), - getString(R.string.set_key_separators) -> onStartIndexing(true) - getString(R.string.set_key_observing) -> { - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (!indexer.isIndexing) { - updateIdleSession() - } - } + override fun onIndexingSettingChanged() { + // Music loading configuration changed, need to reload music. + onStartIndexing(true) + } + + override fun onObservingChanged() { + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (!indexer.isIndexing) { + updateIdleSession() } } 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 1ca050570..040240308 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -65,7 +65,7 @@ class PlaybackBarFragment : ViewBindingFragment() { // Set up actions binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } - setupSecondaryActions(binding, PlaybackSettings.from(context).playbackBarAction) + setupSecondaryActions(binding, PlaybackSettings.from(context).barAction) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 8832de932..9e46e5946 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -31,11 +31,11 @@ import org.oxycblt.auxio.util.logD * User configuration specific to the playback system. * @author Alexander Capehart (OxygenCobalt) */ -interface PlaybackSettings : Settings { +interface PlaybackSettings : Settings { /** The action to display on the playback bar. */ - val playbackBarAction: ActionMode + val barAction: ActionMode /** The action to display in the playback notification. */ - val playbackNotificationAction: ActionMode + val notificationAction: ActionMode /** Whether to start playback when a headset is plugged in. */ val headsetAutoplay: Boolean /** The current ReplayGain configuration. */ @@ -59,75 +59,72 @@ interface PlaybackSettings : Settings { /** Whether a song should pause after every repeat. */ val pauseOnRepeat: Boolean - private class Real(context: Context) : Settings.Real(context), PlaybackSettings { + interface Listener { + /** Called when one of the ReplayGain configurations have changed. */ + fun onReplayGainSettingsChanged() {} + /** Called when [notificationAction] has changed. */ + fun onNotificationActionChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), PlaybackSettings { override val inListPlaybackMode: MusicMode get() = MusicMode.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_library_song_playback_mode), - Int.MIN_VALUE)) + getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) ?: MusicMode.SONGS override val inParentPlaybackMode: MusicMode? get() = MusicMode.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_detail_song_playback_mode), - Int.MIN_VALUE)) + getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE)) - override val playbackBarAction: ActionMode + override val barAction: ActionMode get() = ActionMode.fromIntCode( - sharedPreferences.getInt( - context.getString(R.string.set_key_bar_action), Int.MIN_VALUE)) + sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE)) ?: ActionMode.NEXT - override val playbackNotificationAction: ActionMode + override val notificationAction: ActionMode get() = ActionMode.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_notif_action), Int.MIN_VALUE)) + getString(R.string.set_key_notif_action), Int.MIN_VALUE)) ?: ActionMode.REPEAT override val headsetAutoplay: Boolean get() = - sharedPreferences.getBoolean( - context.getString(R.string.set_key_headset_autoplay), false) + sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false) override val replayGainMode: ReplayGainMode get() = ReplayGainMode.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) + getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) ?: ReplayGainMode.DYNAMIC override var replayGainPreAmp: ReplayGainPreAmp get() = ReplayGainPreAmp( - sharedPreferences.getFloat( - context.getString(R.string.set_key_pre_amp_with), 0f), - sharedPreferences.getFloat( - context.getString(R.string.set_key_pre_amp_without), 0f)) + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f), + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f)) set(value) { sharedPreferences.edit { - putFloat(context.getString(R.string.set_key_pre_amp_with), value.with) - putFloat(context.getString(R.string.set_key_pre_amp_without), value.without) + putFloat(getString(R.string.set_key_pre_amp_with), value.with) + putFloat(getString(R.string.set_key_pre_amp_without), value.without) apply() } } override val keepShuffle: Boolean - get() = - sharedPreferences.getBoolean(context.getString(R.string.set_key_keep_shuffle), true) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true) override val rewindWithPrev: Boolean - get() = - sharedPreferences.getBoolean(context.getString(R.string.set_key_rewind_prev), true) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true) override val pauseOnRepeat: Boolean - get() = - sharedPreferences.getBoolean( - context.getString(R.string.set_key_repeat_pause), false) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) override fun migrate() { // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. @@ -142,7 +139,7 @@ interface PlaybackSettings : Settings { } sharedPreferences.edit { - putInt(context.getString(R.string.set_key_notif_action), mode.intCode) + putInt(getString(R.string.set_key_notif_action), mode.intCode) remove(OLD_KEY_ALT_NOTIF_ACTION) apply() } @@ -170,9 +167,7 @@ interface PlaybackSettings : Settings { ?: MusicMode.SONGS sharedPreferences.edit { - putInt( - context.getString(R.string.set_key_library_song_playback_mode), - mode.intCode) + putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode) remove(OLD_KEY_LIB_PLAYBACK_MODE) apply() } @@ -188,7 +183,7 @@ interface PlaybackSettings : Settings { sharedPreferences.edit { putInt( - context.getString(R.string.set_key_detail_song_playback_mode), + getString(R.string.set_key_in_parent_playback_mode), mode?.intCode ?: Int.MIN_VALUE) remove(OLD_KEY_DETAIL_PLAYBACK_MODE) apply() @@ -196,6 +191,14 @@ interface PlaybackSettings : Settings { } } + override fun onSettingChanged(key: String, listener: Listener) { + if (key == getString(R.string.set_key_replay_gain) || + key == getString(R.string.set_key_pre_amp_with) || + key == getString(R.string.set_key_pre_amp_without)) { + listener.onReplayGainSettingsChanged() + } + } + companion object { const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" 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 8461eb6a1..3a2a39893 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 @@ -18,7 +18,6 @@ package org.oxycblt.auxio.playback.replaygain import android.content.Context -import android.content.SharedPreferences import com.google.android.exoplayer2.C import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.Player @@ -28,7 +27,6 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor import com.google.android.exoplayer2.util.MimeTypes import java.nio.ByteBuffer import kotlin.math.pow -import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.extractor.TextTags import org.oxycblt.auxio.playback.PlaybackSettings @@ -45,10 +43,10 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor(private val context: Context) : - BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener { +class ReplayGainAudioProcessor(context: Context) : + BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = PlaybackSettings.from(context) + private val playbackSettings = PlaybackSettings.from(context) private var lastFormat: Format? = null private var volume = 1f @@ -65,7 +63,7 @@ class ReplayGainAudioProcessor(private val context: Context) : */ fun addToListeners(player: Player) { player.addListener(this) - settings.addListener(this) + playbackSettings.registerListener(this) } /** @@ -75,7 +73,7 @@ class ReplayGainAudioProcessor(private val context: Context) : */ fun releaseFromListeners(player: Player) { player.removeListener(this) - settings.removeListener(this) + playbackSettings.unregisterListener(this) } // --- OVERRIDES --- @@ -98,13 +96,9 @@ class ReplayGainAudioProcessor(private val context: Context) : applyReplayGain(null) } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == context.getString(R.string.set_key_replay_gain) || - key == context.getString(R.string.set_key_pre_amp_with) || - key == context.getString(R.string.set_key_pre_amp_without)) { - // ReplayGain changed, we need to set it up again. - applyReplayGain(lastFormat) - } + override fun onReplayGainSettingsChanged() { + // ReplayGain config changed, we need to set it up again. + applyReplayGain(lastFormat) } // --- REPLAYGAIN PARSING --- @@ -116,14 +110,14 @@ class ReplayGainAudioProcessor(private val context: Context) : private fun applyReplayGain(format: Format?) { lastFormat = format val gain = parseReplayGain(format ?: return) - val preAmp = settings.replayGainPreAmp + val preAmp = playbackSettings.replayGainPreAmp val adjust = if (gain != null) { logD("Found ReplayGain adjustment $gain") // ReplayGain is configurable, so determine what to do based off of the mode. val useAlbumGain = - when (settings.replayGainMode) { + when (playbackSettings.replayGainMode) { // User wants track gain to be preferred. Default to album gain only if // there is no track gain. ReplayGainMode.TRACK -> gain.track == 0f 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 8a88c400e..d404b92f1 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 @@ -116,7 +116,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun registerInternalPlayer(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer != null) { + if (this.internalPlayer != null) { logW("Internal player is already registered") return } @@ -141,7 +141,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + if (this.internalPlayer !== internalPlayer) { logW("Given internal player did not match current internal player") return } 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 c00b943b6..bd6900c51 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 @@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.system import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -31,6 +30,7 @@ import androidx.media.session.MediaButtonReceiver import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode @@ -51,7 +51,8 @@ import org.oxycblt.auxio.util.logD class MediaSessionComponent(private val context: Context, private val listener: Listener) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener, - SharedPreferences.OnSharedPreferenceChangeListener { + ImageSettings.Listener, + PlaybackSettings.Listener { private val mediaSession = MediaSessionCompat(context, context.packageName).apply { isActive = true @@ -59,13 +60,14 @@ class MediaSessionComponent(private val context: Context, private val listener: } private val playbackManager = PlaybackStateManager.getInstance() - private val settings = PlaybackSettings.from(context) + private val playbackSettings = PlaybackSettings.from(context) private val notification = NotificationComponent(context, mediaSession.sessionToken) private val provider = BitmapProvider(context) init { playbackManager.addListener(this) + playbackSettings.registerListener(this) mediaSession.setCallback(this) } @@ -83,7 +85,7 @@ class MediaSessionComponent(private val context: Context, private val listener: */ fun release() { provider.release() - settings.removeListener(this) + playbackSettings.unregisterListener(this) playbackManager.removeListener(this) mediaSession.apply { isActive = false @@ -150,12 +152,14 @@ class MediaSessionComponent(private val context: Context, private val listener: // --- SETTINGS OVERRIDES --- - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - context.getString(R.string.set_key_cover_mode) -> - updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) - context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction() - } + override fun onCoverModeChanged() { + // Need to reload the metadata cover. + updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) + } + + override fun onNotificationActionChanged() { + // Need to re-load the action shown in the notification. + invalidateSecondaryAction() } // --- MEDIASESSION OVERRIDES --- @@ -359,7 +363,7 @@ class MediaSessionComponent(private val context: Context, private val listener: // Add the secondary action (either repeat/shuffle depending on the configuration) val secondaryAction = - when (settings.playbackNotificationAction) { + when (playbackSettings.notificationAction) { ActionMode.SHUFFLE -> PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INVERT_SHUFFLE, @@ -393,7 +397,7 @@ class MediaSessionComponent(private val context: Context, private val listener: private fun invalidateSecondaryAction() { invalidateSessionState() - when (settings.playbackNotificationAction) { + when (playbackSettings.notificationAction) { ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled) else -> notification.updateRepeatMode(playbackManager.repeatMode) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt index 05dac481a..881bc8940 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt @@ -27,21 +27,20 @@ import org.oxycblt.auxio.settings.Settings * User configuration specific to the search UI. * @author Alexander Capehart (OxygenCobalt) */ -interface SearchSettings : Settings { +interface SearchSettings : Settings { /** The type of Music the search view is currently filtering to. */ var searchFilterMode: MusicMode? - private class Real(context: Context) : Settings.Real(context), SearchSettings { + private class Real(context: Context) : Settings.Real(context), SearchSettings { override var searchFilterMode: MusicMode? get() = MusicMode.fromIntCode( sharedPreferences.getInt( - context.getString(R.string.set_key_search_filter), Int.MIN_VALUE)) + getString(R.string.set_key_search_filter), Int.MIN_VALUE)) set(value) { sharedPreferences.edit { putInt( - context.getString(R.string.set_key_search_filter), - value?.intCode ?: Int.MIN_VALUE) + getString(R.string.set_key_search_filter), value?.intCode ?: Int.MIN_VALUE) apply() } } 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 f9a82fca7..c1804d859 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -19,49 +19,75 @@ package org.oxycblt.auxio.settings import android.content.Context import android.content.SharedPreferences +import androidx.annotation.StringRes import androidx.preference.PreferenceManager +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Abstract user configuration information. This interface has no functionality whatsoever. Concrete * implementations should be preferred instead. * @author Alexander Capehart (OxygenCobalt) */ -interface Settings { - /** Migrate any settings fields from older versions into their new counterparts. */ +interface Settings { + /** + * Migrate any settings fields from older versions into their new counterparts. + * @throws NotImplementedError If there is nothing to migrate. + */ fun migrate() { throw NotImplementedError() } /** - * Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates. - * @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add. + * Add a listener to monitor for settings updates. Will do nothing if + * @param listener The listener to add. */ - fun addListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - throw NotImplementedError() - } + fun registerListener(listener: L) /** - * Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further - * settings updates from being sent to ti.t + * Unregister a listener, preventing any further settings updates from being sent to it. + * @param listener The listener to unregister, must be the same as the current listener. */ - fun removeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - throw NotImplementedError() - } + fun unregisterListener(listener: L) /** * A framework-backed [Settings] implementation. * @param context [Context] required. */ - abstract class Real(protected val context: Context) : Settings { + abstract class Real(private val context: Context) : + Settings, SharedPreferences.OnSharedPreferenceChangeListener { protected val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - override fun addListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + /** @see [Context.getString] */ + protected fun getString(@StringRes stringRes: Int) = context.getString(stringRes) + + private var listener: L? = null + + override fun registerListener(listener: L) { + if (this.listener == null) { + // Registering a listener when it was null prior, attach the callback. + sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + this.listener = listener } - override fun removeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + override fun unregisterListener(listener: L) { + if (this.listener !== listener) { + logW("Given listener was not the current listener.") + } + this.listener = null + // No longer have a listener, detach from the preferences instance. + sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) } + + final override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: String + ) { + onSettingChanged(key, unlikelyToBeNull(listener)) + } + + open fun onSettingChanged(key: String, listener: L) {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt index cb7bdc82c..b8daa3c7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt @@ -87,7 +87,7 @@ class PreferenceFragment : PreferenceFragmentCompat() { when (preference.key) { getString(R.string.set_key_accent) -> SettingsFragmentDirections.goToAccentDialog() - getString(R.string.set_key_lib_tabs) -> + getString(R.string.set_key_home_tabs) -> SettingsFragmentDirections.goToTabDialog() getString(R.string.set_key_pre_amp) -> SettingsFragmentDirections.goToPreAmpDialog() diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt index 0faf91fbc..dedc5efc4 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -26,7 +26,11 @@ import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.accent.Accent import org.oxycblt.auxio.util.logD -interface UISettings : Settings { +/** + * User configuration for the general app UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface UISettings : Settings { /** The current theme. Represented by the AppCompatDelegate constants. */ val theme: Int /** Whether to use a black background when a dark theme is currently used. */ @@ -36,32 +40,33 @@ interface UISettings : Settings { /** Whether to round additional UI elements that require album covers to be rounded. */ val roundMode: Boolean - private class Real(context: Context) : Settings.Real(context), UISettings { + interface Listener { + /** Called when [roundMode] changes. */ + fun onRoundModeChanged() + } + + private class Real(context: Context) : Settings.Real(context), UISettings { override val theme: Int get() = sharedPreferences.getInt( - context.getString(R.string.set_key_theme), - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + getString(R.string.set_key_theme), AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) override val useBlackTheme: Boolean - get() = - sharedPreferences.getBoolean(context.getString(R.string.set_key_black_theme), false) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_black_theme), false) override var accent: Accent get() = Accent.from( - sharedPreferences.getInt( - context.getString(R.string.set_key_accent), Accent.DEFAULT)) + sharedPreferences.getInt(getString(R.string.set_key_accent), Accent.DEFAULT)) set(value) { sharedPreferences.edit { - putInt(context.getString(R.string.set_key_accent), value.index) + putInt(getString(R.string.set_key_accent), value.index) apply() } } override val roundMode: Boolean - get() = - sharedPreferences.getBoolean(context.getString(R.string.set_key_round_mode), false) + get() = sharedPreferences.getBoolean(getString(R.string.set_key_round_mode), false) override fun migrate() { if (sharedPreferences.contains(OLD_KEY_ACCENT3)) { @@ -78,13 +83,19 @@ interface UISettings : Settings { } sharedPreferences.edit { - putInt(context.getString(R.string.set_key_accent), accent) + putInt(getString(R.string.set_key_accent), accent) remove(OLD_KEY_ACCENT3) apply() } } } + override fun onSettingChanged(key: String, listener: Listener) { + if (key == getString(R.string.set_key_round_mode)) { + listener.onRoundModeChanged() + } + } + companion object { const val OLD_KEY_ACCENT3 = "auxio_accent" } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index a4c62022d..180165d97 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -18,13 +18,13 @@ package org.oxycblt.auxio.widgets import android.content.Context -import android.content.SharedPreferences import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -43,15 +43,17 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class WidgetComponent(private val context: Context) : - PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener { + PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = UISettings.from(context) + private val uiSettings = UISettings.from(context) + private val imageSettings = ImageSettings.from(context) private val widgetProvider = WidgetProvider() private val provider = BitmapProvider(context) init { playbackManager.addListener(this) - settings.addListener(this) + uiSettings.registerListener(this) + imageSettings.registerListener(this) } /** Update [WidgetProvider] with the current playback state. */ @@ -76,7 +78,7 @@ class WidgetComponent(private val context: Context) : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12, always round the cover with the widget's inner radius context.getDimenPixels(android.R.dimen.system_app_widget_inner_radius) - } else if (settings.roundMode) { + } else if (uiSettings.roundMode) { // < Android 12, but the user still enabled round mode. context.getDimenPixels(R.dimen.size_corners_medium) } else { @@ -107,27 +109,23 @@ class WidgetComponent(private val context: Context) : /** Release this instance, preventing any further events from updating the widget instances. */ fun release() { provider.release() - settings.removeListener(this) + uiSettings.unregisterListener(this) widgetProvider.reset(context) playbackManager.removeListener(this) } // --- CALLBACKS --- - // Hook all the major song-changing updates + the major player state updates - // to updating the "Now Playing" widget. + // Respond to all major song or player changes that will affect the widget override fun onIndexMoved(queue: Queue) = update() override fun onQueueReordered(queue: Queue) = update() override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() override fun onStateChanged(state: InternalPlayer.State) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update() - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - if (key == context.getString(R.string.set_key_cover_mode) || - key == context.getString(R.string.set_key_round_mode)) { - update() - } - } + // Respond to settings changes that will affect the widget + override fun onRoundModeChanged() = update() + override fun onCoverModeChanged() = update() /** * A condensed form of the playback state that is safe to use in AppWidgets. diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index b96505ed8..ff0dbbe2e 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -20,8 +20,8 @@ auxio_pre_amp_with auxio_pre_amp_without - auxio_library_playback_mode - auxio_detail_playback_mode + auxio_library_playback_mode + auxio_detail_playback_mode KEY_KEEP_SHUFFLE KEY_PREV_REWIND KEY_LOOP_PAUSE @@ -29,7 +29,7 @@ auxio_wipe_state auxio_restore_state - auxio_lib_tabs + auxio_lib_tabs auxio_hide_collaborators auxio_round_covers auxio_bar_action @@ -37,14 +37,14 @@ KEY_SEARCH_FILTER - auxio_songs_sort - auxio_albums_sort - auxio_artists_sort - auxio_genres_sort + auxio_songs_sort + auxio_albums_sort + auxio_artists_sort + auxio_genres_sort - auxio_album_sort - auxio_artist_sort - auxio_genre_sort + auxio_album_sort + auxio_artist_sort + auxio_genre_sort @string/set_theme_auto diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml index 433581aa9..7e04cd41c 100644 --- a/app/src/main/res/xml/prefs_main.xml +++ b/app/src/main/res/xml/prefs_main.xml @@ -27,7 +27,7 @@ @@ -86,7 +86,7 @@ app:defaultValue="@integer/music_mode_songs" app:entries="@array/entries_library_song_playback_mode" app:entryValues="@array/values_library_song_playback_mode" - app:key="@string/set_key_library_song_playback_mode" + app:key="@string/set_key_in_list_playback_mode" app:title="@string/set_library_song_playback_mode" app:useSimpleSummaryProvider="true" /> @@ -94,7 +94,7 @@ app:defaultValue="@integer/music_mode_none" app:entries="@array/entries_detail_song_playback_mode" app:entryValues="@array/values_detail_song_playback_mode" - app:key="@string/set_key_detail_song_playback_mode" + app:key="@string/set_key_in_parent_playback_mode" app:title="@string/set_detail_song_playback_mode" app:useSimpleSummaryProvider="true" /> diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt index 795e7b29c..a0a3096a6 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -20,6 +20,8 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.music.storage.MusicDirectories interface FakeMusicSettings : MusicSettings { + override fun registerListener(listener: MusicSettings.Listener) = throw NotImplementedError() + override fun unregisterListener(listener: MusicSettings.Listener) = throw NotImplementedError() override var musicDirs: MusicDirectories get() = throw NotImplementedError() set(_) = throw NotImplementedError() From 6fa53ab8739011da0f7a69abc8cbf081a104bcac Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 6 Jan 2023 19:58:52 -0700 Subject: [PATCH 19/55] playback: mostly hide playback mode details Mostly hide the code that handles starting playback based on a given mode into their respective ViewModels. Again, makes testing easier. --- CHANGELOG.md | 1 + .../java/org/oxycblt/auxio/MainFragment.kt | 2 +- .../auxio/detail/AlbumDetailFragment.kt | 19 +++------ .../auxio/detail/ArtistDetailFragment.kt | 18 ++++---- .../oxycblt/auxio/detail/DetailViewModel.kt | 8 ++++ .../auxio/detail/GenreDetailFragment.kt | 21 +++++----- .../detail/recycler/AlbumDetailAdapter.kt | 2 +- .../detail/recycler/ArtistDetailAdapter.kt | 5 ++- .../auxio/detail/recycler/DetailAdapter.kt | 6 +-- .../detail/recycler/GenreDetailAdapter.kt | 5 ++- .../org/oxycblt/auxio/home/HomeViewModel.kt | 7 +++- .../auxio/home/list/SongListFragment.kt | 7 +--- .../auxio/playback/PlaybackBarFragment.kt | 2 +- .../auxio/playback/PlaybackViewModel.kt | 41 ++++++++++++++++--- .../oxycblt/auxio/search/SearchFragment.kt | 8 +--- .../oxycblt/auxio/search/SearchViewModel.kt | 13 ++++-- 16 files changed, 96 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d974ead12..a5effd8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed crash that would occur in music folders dialog when user does not have a working file manager - Fixed notification not updating due to settings changes +- Fixed genre picker from repeatedly showing up when device rotates #### What's Changed - Implemented new queue system diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c3a3060ce..d13508542 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -309,7 +309,7 @@ class MainFragment : navModel.mainNavigateTo( MainNavigationAction.Directions( MainFragmentDirections.actionPickPlaybackGenre(song.uid))) - playbackModel.finishPlaybackArtistPicker() + playbackModel.finishPlaybackGenrePicker() } } 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 a69841ff0..b46751f12 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.* * @author Alexander Capehart (OxygenCobalt) */ class AlbumDetailFragment : - ListFragment(), AlbumDetailAdapter.Listener { + ListFragment(), AlbumDetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. @@ -121,21 +121,12 @@ class AlbumDetailFragment : } } - override fun onRealClick(item: Music) { - val song = requireIs(item) - when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { - // "Play from shown item" and "Play from album" functionally have the same - // behavior since a song can only have one album. - null, - MusicMode.ALBUMS -> playbackModel.playFromAlbum(song) - MusicMode.SONGS -> playbackModel.playFromAll(song) - MusicMode.ARTISTS -> playbackModel.playFromArtist(song) - MusicMode.GENRES -> playbackModel.playFromGenre(song) - } + override fun onRealClick(item: Song) { + // There can only be one album, so a null mode and an ALBUMS mode will function the same. + playbackModel.playFrom(item, detailModel.playbackMode ?: MusicMode.ALBUMS) } - override fun onOpenMenu(item: Music, anchor: View) { - check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onOpenMenu(item: Song, anchor: View) { openMusicMenu(anchor, R.menu.menu_album_song_actions, item) } 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 7fbe191a8..e9317c06d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -48,7 +48,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information about an [Artist]. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { +class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an artist. @@ -122,20 +122,18 @@ class ArtistDetailFragment : ListFragment(), Detai override fun onRealClick(item: Music) { when (item) { + is Album -> navModel.exploreNavigateTo(item) is Song -> { - when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { + val playbackMode = detailModel.playbackMode + if (playbackMode != null) { + playbackModel.playFrom(item, playbackMode) + } else { // When configured to play from the selected item, we already have an Artist // to play from. - null -> - playbackModel.playFromArtist( - item, unlikelyToBeNull(detailModel.currentArtist.value)) - MusicMode.SONGS -> playbackModel.playFromAll(item) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> playbackModel.playFromGenre(item) + playbackModel.playFromArtist(item, + unlikelyToBeNull(detailModel.currentArtist.value)) } } - is Album -> navModel.exploreNavigateTo(item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } 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 4d5db2ec0..10118050c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** @@ -50,6 +51,7 @@ class DetailViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private var currentSongJob: Job? = null @@ -125,6 +127,12 @@ class DetailViewModel(application: Application) : currentGenre.value?.let(::refreshGenreList) } + /** + * The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently + * shown item. + */ + val playbackMode: MusicMode? get() = playbackSettings.inParentPlaybackMode + init { musicStore.addListener(this) } 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 38568826e..f5e7bdd24 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information for a particular [Genre]. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { +class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an genre. @@ -122,18 +122,17 @@ class GenreDetailFragment : ListFragment(), Detail override fun onRealClick(item: Music) { when (item) { is Artist -> navModel.exploreNavigateTo(item) - is Song -> - when (PlaybackSettings.from(requireContext()).inParentPlaybackMode) { - // When configured to play from the selected item, we already have a Genre + is Song -> { + val playbackMode = detailModel.playbackMode + if (playbackMode != null) { + playbackModel.playFrom(item, playbackMode) + } else { + // When configured to play from the selected item, we already have an Artist // to play from. - 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 -> playbackModel.playFromGenre(item) + playbackModel.playFromArtist(item, + unlikelyToBeNull(detailModel.currentArtist.value)) } + } else -> error("Unexpected datatype: ${item::class.simpleName}") } } 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 fb80c5ba5..56a86cc6f 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 @@ -48,7 +48,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene * An extension to [DetailAdapter.Listener] that enables interactions specific to the album * detail view. */ - interface Listener : DetailAdapter.Listener { + interface Listener : DetailAdapter.Listener { /** * Called when the artist name in the [Album] header was clicked, requesting navigation to * it's parent artist. 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 ddcc5ceb7..e8cdb0d6e 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 @@ -32,6 +32,7 @@ import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SimpleItemCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -42,7 +43,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { // Support an artist header, and special artist albums/songs. @@ -109,7 +110,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It * @param artist The new [Artist] to bind. * @param listener A [DetailAdapter.Listener] to bind interactions to. */ - fun bind(artist: Artist, listener: DetailAdapter.Listener) { + fun bind(artist: Artist, listener: DetailAdapter.Listener<*>) { binding.detailCover.bind(artist) binding.detailType.text = binding.context.getString(R.string.lbl_artist) binding.detailName.text = artist.resolveName(binding.context) 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 1bc9d25ad..7a365b58a 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 @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.inflater * @author Alexander Capehart (OxygenCobalt) */ abstract class DetailAdapter( - private val listener: Listener, + private val listener: Listener<*>, itemCallback: DiffUtil.ItemCallback ) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { // Safe to leak this since the listener will not fire during initialization @@ -89,7 +89,7 @@ abstract class DetailAdapter( } /** An extended [SelectableListListener] for [DetailAdapter] implementations. */ - interface Listener : SelectableListListener { + interface Listener : SelectableListListener { // TODO: Split off into sub-listeners if a collapsing toolbar is implemented. /** * Called when the play button in a detail header is pressed, requesting that the current @@ -139,7 +139,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : * @param sortHeader The new [SortHeader] to bind. * @param listener An [DetailAdapter.Listener] to bind interactions to. */ - fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) { + fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener<*>) { binding.headerTitle.text = binding.context.getString(sortHeader.titleRes) binding.headerButton.apply { // Add a Tooltip based on the content description so that the purpose of this 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 fad27aa8a..6533b8168 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 @@ -30,6 +30,7 @@ import org.oxycblt.auxio.list.recycler.SimpleItemCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -40,7 +41,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { // Support the Genre header and generic Artist/Song items. There's nothing about @@ -105,7 +106,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite * @param genre The new [Song] to bind. * @param listener A [DetailAdapter.Listener] to bind interactions to. */ - fun bind(genre: Genre, listener: DetailAdapter.Listener) { + fun bind(genre: Genre, listener: DetailAdapter.Listener<*>) { binding.detailCover.bind(genre) binding.detailType.text = binding.context.getString(R.string.lbl_genre) binding.detailName.text = genre.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 2798f64f3..cd5f55b47 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD /** @@ -37,6 +38,7 @@ class HomeViewModel(application: Application) : private val musicStore = MusicStore.getInstance() private val homeSettings = HomeSettings.from(application) private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private val _songsList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -62,11 +64,14 @@ class HomeViewModel(application: Application) : val genresList: StateFlow> get() = _genresList + /** The [MusicMode] to use when playing a [Song] from the UI. */ + val playbackMode: MusicMode get() = playbackSettings.inListPlaybackMode + /** * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabModes: List = makeTabModes() + var currentTabModes = makeTabModes() private set private val _currentTabMode = MutableStateFlow(currentTabModes[0]) 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 2ab60aad5..58b6831b1 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 @@ -130,12 +130,7 @@ class SongListFragment : } override fun onRealClick(item: Song) { - when (PlaybackSettings.from(requireContext()).inListPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(item) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> playbackModel.playFromGenre(item) - } + playbackModel.playFrom(item, homeModel.playbackMode) } override fun onOpenMenu(item: Song, anchor: View) { 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 040240308..a268b9feb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -65,7 +65,7 @@ class PlaybackBarFragment : ViewBindingFragment() { // Set up actions binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } - setupSecondaryActions(binding, PlaybackSettings.from(context).barAction) + setupSecondaryActions(binding, playbackModel.currentBarAction) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources. 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 23f6e880d..96cd6bc6d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -36,7 +36,6 @@ import org.oxycblt.auxio.util.context */ class PlaybackViewModel(application: Application) : AndroidViewModel(application), PlaybackStateManager.Listener { - private val homeSettings = HomeSettings.from(application) private val musicSettings = MusicSettings.from(application) private val playbackSettings = PlaybackSettings.from(application) private val playbackManager = PlaybackStateManager.getInstance() @@ -84,6 +83,9 @@ class PlaybackViewModel(application: Application) : val genrePickerSong: StateFlow get() = _genrePlaybackPickerSong + /** The current action to show on the playback bar. */ + val currentBarAction: ActionMode get() = playbackSettings.barAction + /** * The current audio session ID of the internal player. Null if no [InternalPlayer] is * available. @@ -143,6 +145,29 @@ class PlaybackViewModel(application: Application) : // --- PLAYING FUNCTIONS --- + /** Shuffle all songs in the music library. */ + fun shuffleAll() { + playImpl(null, null, true) + } + + /** + * Play a [Song] from the [MusicParent] outlined by the given [MusicMode]. + * - If [MusicMode.SONGS], the [Song] is played from all songs. + * - If [MusicMode.ALBUMS], the [Song] is played from it's [Album]. + * - If [MusicMode.ARTISTS], the [Song] is played from one of it's [Artist]s. + * - If [MusicMode.GENRES], the [Song] is played from one of it's [Genre]s. + * @param song The [Song] to play. + * @param playbackMode The [MusicMode] to play from. + */ + fun playFrom(song: Song, playbackMode: MusicMode) { + when (playbackMode) { + MusicMode.SONGS -> playFromAll(song) + MusicMode.ALBUMS -> playFromAlbum(song) + MusicMode.ARTISTS -> playFromArtist(song) + MusicMode.GENRES -> playFromGenre(song) + } + } + /** * Play the given [Song] from all songs in the music library. * @param song The [Song] to play. @@ -151,11 +176,6 @@ class PlaybackViewModel(application: Application) : playImpl(song, null) } - /** Shuffle all songs in the music library. */ - fun shuffleAll() { - playImpl(null, null, true) - } - /** * Play a [Song] from it's [Album]. * @param song The [Song] to play. @@ -203,6 +223,15 @@ class PlaybackViewModel(application: Application) : } } + /** + * Mark the [Genre] playback choice process as complete. This should occur when the [Genre] + * choice dialog is opened after this flag is detected. + * @see playFromGenre + */ + fun finishPlaybackGenrePicker() { + _genrePlaybackPickerSong.value = null + } + /** * Play an [Album]. * @param album The [Album] to play. 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 48e037aaa..f70cf7a90 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -136,14 +136,8 @@ class SearchFragment : ListFragment() { override fun onRealClick(item: Music) { when (item) { - is Song -> - when (PlaybackSettings.from(requireContext()).inListPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(item) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(item) - MusicMode.ARTISTS -> playbackModel.playFromArtist(item) - MusicMode.GENRES -> playbackModel.playFromGenre(item) - } is MusicParent -> navModel.exploreNavigateTo(item) + is Song -> playbackModel.playFrom(item, searchModel.playbackMode) } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 1a4ea647d..4782026f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -44,7 +45,8 @@ import org.oxycblt.auxio.util.logD class SearchViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() - private val settings = SearchSettings.from(application) + private val searchSettings = SearchSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -53,6 +55,9 @@ class SearchViewModel(application: Application) : val searchResults: StateFlow> get() = _searchResults + /** The [MusicMode] to use when playing a [Song] from the UI. */ + val playbackMode: MusicMode get() = playbackSettings.inListPlaybackMode + init { musicStore.addListener(this) } @@ -97,7 +102,7 @@ class SearchViewModel(application: Application) : private fun searchImpl(library: Library, query: String): List { val sort = Sort(Sort.Mode.ByName, true) - val filterMode = settings.searchFilterMode + val filterMode = searchSettings.searchFilterMode val results = mutableListOf() // Note: A null filter mode maps to the "All" filter option, hence the check. @@ -182,7 +187,7 @@ class SearchViewModel(application: Application) : */ @IdRes fun getFilterOptionId() = - when (settings.searchFilterMode) { + when (searchSettings.searchFilterMode) { MusicMode.SONGS -> R.id.option_filter_songs MusicMode.ALBUMS -> R.id.option_filter_albums MusicMode.ARTISTS -> R.id.option_filter_artists @@ -207,7 +212,7 @@ class SearchViewModel(application: Application) : else -> error("Invalid option ID provided") } logD("Updating filter mode to $newFilterMode") - settings.searchFilterMode = newFilterMode + searchSettings.searchFilterMode = newFilterMode search(lastQuery) } From dc73f96ba892b49dbe343e700b1e9f1b53ff57ff Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 7 Jan 2023 08:19:12 -0700 Subject: [PATCH 20/55] list: clear selection before navigating back When the back button is pressed, clear the current selection before navigating back. This is something I was planning to do but then completely forgot about when implementing multi-select. Resolves #316. --- CHANGELOG.md | 1 + app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 6 ++++++ .../java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt | 1 - .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 9 ++++----- .../java/org/oxycblt/auxio/detail/DetailViewModel.kt | 3 ++- .../java/org/oxycblt/auxio/detail/GenreDetailFragment.kt | 9 ++++----- .../oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt | 3 ++- .../oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt | 3 ++- .../main/java/org/oxycblt/auxio/home/HomeViewModel.kt | 3 ++- .../java/org/oxycblt/auxio/home/list/SongListFragment.kt | 1 - .../java/org/oxycblt/auxio/playback/PlaybackViewModel.kt | 4 ++-- .../main/java/org/oxycblt/auxio/search/SearchFragment.kt | 2 -- .../java/org/oxycblt/auxio/search/SearchViewModel.kt | 3 ++- 13 files changed, 27 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5effd8d0..b1bd13e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ #### What's Improved - Added ability to edit previously played or currently playing items in the queue - Added support for date values formatted as "YYYYMMDD" +- Pressing the button will now clear the current selection before navigating back #### What's Fixed - Fixed unreliable ReplayGain adjustment application in certain situations diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index d13508542..8b0c32112 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -403,6 +403,11 @@ class MainFragment : return } + // Clear out any prior selections. + if (selectionModel.consume().isNotEmpty()) { + return + } + // Then try to navigate out of the explore navigation fragments (i.e Detail Views) binding.exploreNavHost.findNavController().navigateUp() } @@ -427,6 +432,7 @@ class MainFragment : isEnabled = queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED || playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED || + selectionModel.selected.value.isNotEmpty() || exploreNavController.currentDestination?.id != exploreNavController.graph.startDestinationId } 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 b46751f12..3c6925e22 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -37,7 +37,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** 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 e9317c06d..ff5ecd6d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -33,11 +33,9 @@ import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -48,7 +46,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information about an [Artist]. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { +class ArtistDetailFragment : + ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an artist. @@ -130,8 +129,8 @@ class ArtistDetailFragment : ListFragment(), Detai } else { // When configured to play from the selected item, we already have an Artist // to play from. - playbackModel.playFromArtist(item, - unlikelyToBeNull(detailModel.currentArtist.value)) + playbackModel.playFromArtist( + item, unlikelyToBeNull(detailModel.currentArtist.value)) } } else -> error("Unexpected datatype: ${item::class.simpleName}") 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 10118050c..fd6f97b5a 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -131,7 +131,8 @@ class DetailViewModel(application: Application) : * The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently * shown item. */ - val playbackMode: MusicMode? get() = playbackSettings.inParentPlaybackMode + val playbackMode: MusicMode? + get() = playbackSettings.inParentPlaybackMode init { musicStore.addListener(this) 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 f5e7bdd24..36c3815fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -34,11 +34,9 @@ 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.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -49,7 +47,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information for a particular [Genre]. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { +class GenreDetailFragment : + ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an genre. @@ -129,8 +128,8 @@ class GenreDetailFragment : ListFragment(), Detail } else { // When configured to play from the selected item, we already have an Artist // to play from. - playbackModel.playFromArtist(item, - unlikelyToBeNull(detailModel.currentArtist.value)) + playbackModel.playFromArtist( + item, unlikelyToBeNull(detailModel.currentArtist.value)) } } else -> error("Unexpected datatype: ${item::class.simpleName}") 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 e8cdb0d6e..ceb7e9660 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 @@ -43,7 +43,8 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class ArtistDetailAdapter(private val listener: Listener) : + DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { // Support an artist header, and special artist albums/songs. 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 6533b8168..e956c5a91 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 @@ -41,7 +41,8 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class GenreDetailAdapter(private val listener: Listener) : + DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { // Support the Genre header and generic Artist/Song items. There's nothing about diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index cd5f55b47..8775e99c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -65,7 +65,8 @@ class HomeViewModel(application: Application) : get() = _genresList /** The [MusicMode] to use when playing a [Song] from the UI. */ - val playbackMode: MusicMode get() = playbackSettings.inListPlaybackMode + val playbackMode: MusicMode + get() = playbackSettings.inListPlaybackMode /** * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible 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 58b6831b1..2a15e79b8 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 @@ -37,7 +37,6 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately 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 96cd6bc6d..e44651763 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.music.* import org.oxycblt.auxio.playback.state.* import org.oxycblt.auxio.util.context @@ -84,7 +83,8 @@ class PlaybackViewModel(application: Application) : get() = _genrePlaybackPickerSong /** The current action to show on the playback bar. */ - val currentBarAction: ActionMode get() = playbackSettings.barAction + val currentBarAction: ActionMode + get() = playbackSettings.barAction /** * The current audio session ID of the internal player. Null if no [InternalPlayer] is 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 f70cf7a90..2c837a888 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -35,10 +35,8 @@ 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.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 4782026f2..b543898b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -56,7 +56,8 @@ class SearchViewModel(application: Application) : get() = _searchResults /** The [MusicMode] to use when playing a [Song] from the UI. */ - val playbackMode: MusicMode get() = playbackSettings.inListPlaybackMode + val playbackMode: MusicMode + get() = playbackSettings.inListPlaybackMode init { musicStore.addListener(this) From a2b51825e8fba020ae481dd522990227b2db44d0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 7 Jan 2023 09:14:36 -0700 Subject: [PATCH 21/55] music: add tests for album types Add tests for Album.Type. Other tests for the music library will be done separately. --- .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 4 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 4 +- .../auxio/home/list/AlbumListFragment.kt | 2 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 2 +- .../auxio/image/extractor/Components.kt | 2 +- .../list/selection/SelectionViewModel.kt | 2 +- .../java/org/oxycblt/auxio/music/Music.kt | 1 + .../org/oxycblt/auxio/music/MusicSettings.kt | 1 + .../org/oxycblt/auxio/music/MusicStore.kt | 2 + .../auxio/music/{ => library}/Library.kt | 6 +- .../oxycblt/auxio/music/{ => library}/Sort.kt | 5 +- .../auxio/music/picker/PickerViewModel.kt | 2 +- .../org/oxycblt/auxio/music/system/Indexer.kt | 2 +- .../playback/state/PlaybackStateDatabase.kt | 2 +- .../playback/state/PlaybackStateManager.kt | 4 +- .../auxio/playback/system/PlaybackService.kt | 2 +- .../oxycblt/auxio/search/SearchViewModel.kt | 4 +- .../org/oxycblt/auxio/music/AlbumTypeTest.kt | 82 ++++++++++++++ .../java/org/oxycblt/auxio/music/DateTest.kt | 104 ++++++------------ .../oxycblt/auxio/music/FakeMusicSettings.kt | 1 + 26 files changed, 147 insertions(+), 101 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{ => library}/Library.kt (97%) rename app/src/main/java/org/oxycblt/auxio/music/{ => library}/Sort.kt (99%) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt 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 3c6925e22..bbc6d07ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -36,7 +36,7 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.util.* /** 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 ff5ecd6d1..66d25fe08 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD 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 fd6f97b5a..81282e354 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -33,9 +33,9 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* 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 36c3815fc..e72e2753c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -36,7 +36,7 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD 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 8b89fef47..c1e0278a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -50,8 +50,8 @@ import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 8775e99c3..c49fb75f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,9 +23,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD 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 011ad304c..8cae8c396 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 @@ -34,7 +34,7 @@ import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately 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 f87a1a458..eaa0bfa2d 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 @@ -34,7 +34,7 @@ import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.nonZeroOrNull diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index 50ed0fc04..d0989bd56 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -34,7 +34,7 @@ import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately 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 2a15e79b8..da42fbd9a 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 @@ -36,7 +36,7 @@ import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately 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 8c9acd412..a324690a2 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 @@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort /** * A [Keyer] implementation for [Music] data. diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index cb42d096e..a607b9cd6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -21,8 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.library.Library /** * A [ViewModel] that manages the current selection. 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 3c8c031be..481a2116b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -30,6 +30,7 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.storage.* diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index 465f7c8df..b96a97fbd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -21,6 +21,7 @@ import android.content.Context import android.os.storage.StorageManager import androidx.core.content.edit import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.storage.MusicDirectories import org.oxycblt.auxio.settings.Settings 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 6b5ea25b1..cb04ef3e4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -17,6 +17,8 @@ package org.oxycblt.auxio.music +import org.oxycblt.auxio.music.library.Library + /** * A repository granting access to the music library. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/Library.kt index e85bc28d6..88cbe302f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.library import android.content.Context import android.net.Uri @@ -43,10 +43,10 @@ class Library(rawSongs: List, settings: MusicSettings) { /** All [Genre]s found on the device. */ val genres = buildGenres(songs) + // Use a mapping to make finding information based on it's UID much faster. private val uidMap = buildMap { - // We need to finalize the newly-created music and also add it to a mapping to make - // de-serializing music from UIDs much faster. Do these in the same loop for efficiency. for (music in (songs + albums + artists + genres)) { + // Finalize all music in the same mapping creation loop for efficiency. music._finalize() this[music.uid] = music } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/Sort.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt index 5c8fa818f..01d57a57d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt @@ -15,13 +15,14 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.library import androidx.annotation.IdRes import kotlin.math.max import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Sort.Mode +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Sort.Mode /** * A sorting method. 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 3c6732f58..c92334228 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 @@ -21,8 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** 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 826a77b3a..45215a8d3 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 @@ -28,8 +28,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.extractor.* +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW 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 b9ea4a594..8aea48e50 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 @@ -24,7 +24,7 @@ import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns import androidx.core.database.sqlite.transaction import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.* /** 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 d404b92f1..9114450fc 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 @@ -21,9 +21,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE 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 790445ad3..df7da5c53 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 @@ -43,10 +43,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.InternalPlayer diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index b543898b7..9341a7390 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -31,9 +31,9 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.Library import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD diff --git a/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt new file mode 100644 index 000000000..ea4581f54 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AlbumTypeTest { + @Test + fun albumType_parse_primary() { + assertEquals(Album.Type.Album(null), Album.Type.parse(listOf("album"))) + assertEquals(Album.Type.EP(null), Album.Type.parse(listOf("ep"))) + assertEquals(Album.Type.Single(null), Album.Type.parse(listOf("single"))) + } + + @Test + fun albumType_parse_secondary() { + assertEquals(Album.Type.Compilation(null), Album.Type.parse(listOf("album", "compilation"))) + assertEquals(Album.Type.Soundtrack, Album.Type.parse(listOf("album", "soundtrack"))) + assertEquals(Album.Type.Mix, Album.Type.parse(listOf("album", "dj-mix"))) + assertEquals(Album.Type.Mixtape, Album.Type.parse(listOf("album", "mixtape/street"))) + } + + @Test + fun albumType_parse_modifiers() { + assertEquals( + Album.Type.Album(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("album", "live"))) + assertEquals( + Album.Type.Album(Album.Type.Refinement.REMIX), + Album.Type.parse(listOf("album", "remix"))) + assertEquals( + Album.Type.EP(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("ep", "live"))) + assertEquals( + Album.Type.EP(Album.Type.Refinement.REMIX), Album.Type.parse(listOf("ep", "remix"))) + assertEquals( + Album.Type.Single(Album.Type.Refinement.LIVE), + Album.Type.parse(listOf("single", "live"))) + assertEquals( + Album.Type.Single(Album.Type.Refinement.REMIX), + Album.Type.parse(listOf("single", "remix"))) + } + + @Test + fun albumType_parse_secondaryModifiers() { + assertEquals( + Album.Type.Compilation(Album.Type.Refinement.LIVE), + Album.Type.parse(listOf("album", "compilation", "live"))) + assertEquals( + Album.Type.Compilation(Album.Type.Refinement.REMIX), + Album.Type.parse(listOf("album", "compilation", "remix"))) + } + + @Test + fun albumType_parse_orphanedSecondary() { + assertEquals(Album.Type.Compilation(null), Album.Type.parse(listOf("compilation"))) + assertEquals(Album.Type.Soundtrack, Album.Type.parse(listOf("soundtrack"))) + assertEquals(Album.Type.Mix, Album.Type.parse(listOf("dj-mix"))) + assertEquals(Album.Type.Mixtape, Album.Type.parse(listOf("mixtape/street"))) + } + + @Test + fun albumType_parse_orphanedModifier() { + assertEquals(Album.Type.Album(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("live"))) + assertEquals( + Album.Type.Album(Album.Type.Refinement.REMIX), Album.Type.parse(listOf("remix"))) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt index 721cfd629..bd21969fc 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt @@ -23,35 +23,28 @@ import org.junit.Test class DateTest { @Test - fun date_equals() { - assertTrue( - requireNotNull(Date.from("2016-08-16T00:01:02")) == - requireNotNull(Date.from("2016-08-16T00:01:02"))) - } - - @Test - fun date_precisionEquals() { + fun date_equals_varyingPrecision() { assertTrue( requireNotNull(Date.from("2016-08-16T00:01:02")) != requireNotNull(Date.from("2016-08-16"))) } @Test - fun date_compareDates() { + fun date_compareTo_dates() { val a = requireNotNull(Date.from("2016-08-16T00:01:02")) val b = requireNotNull(Date.from("2016-09-16T00:01:02")) assertEquals(-1, a.compareTo(b)) } @Test - fun date_compareTimes() { + fun date_compareTo_times() { val a = requireNotNull(Date.from("2016-08-16T00:02:02")) val b = requireNotNull(Date.from("2016-08-16T00:01:02")) assertEquals(1, a.compareTo(b)) } @Test - fun date_comparePrecision() { + fun date_compareTo_varyingPrecision() { val a = requireNotNull(Date.from("2016-08-16T00:01:02")) val b = requireNotNull(Date.from("2016-08-16")) assertEquals( @@ -61,77 +54,42 @@ class DateTest { } @Test - fun date_fromYear() { + fun date_from_values() { assertEquals("2016", Date.from(2016).toString()) - } - - @Test - fun date_fromDate() { assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) - } - - @Test - fun date_fromDatetime() { assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) } @Test - fun date_fromFormalTimestamp() { - assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16T00:01:02").toString()) - } - - @Test - fun date_fromSpacedTimestamp() { - assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16 00:01:02").toString()) - } - - @Test - fun date_fromDatestamp() { - assertEquals( - "2016-08-16", - Date.from("2016-08-16").toString(), - ) - } - - @Test - fun date_fromWeirdDateTimestamp() { - assertEquals("2016-08-16T00:01Z", Date.from("2016-08-16T00:01").toString()) - } - - @Test - fun date_fromWeirdDatestamp() { - assertEquals("2016-08", Date.from("2016-08").toString()) - } - - @Test - fun date_fromYearStamp() { - assertEquals("2016", Date.from("2016").toString()) - } - - @Test - fun date_fromWackTimestamp() { - assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) - } - - @Test - fun date_fromBustedTimestamp() { - assertEquals(null, Date.from("2016-08-16:00:01:02")) - assertEquals(null, Date.from("")) - } - - @Test - fun date_fromWackYear() { - assertEquals(Date.from(0), null) - } - - @Test - fun date_fromYearDate() { + fun date_from_yearDate() { assertEquals("2016-08-16", Date.from(20160816).toString()) assertEquals("2016-08-16", Date.from("20160816").toString()) } @Test - fun dateRange_fromDates() { + fun date_from_timestamp() { + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16T00:01:02").toString()) + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16 00:01:02").toString()) + } + + @Test + fun date_from_lesserPrecision() { + assertEquals("2016", Date.from("2016").toString()) + assertEquals("2016-08", Date.from("2016-08").toString()) + assertEquals("2016-08-16", Date.from("2016-08-16").toString()) + assertEquals("2016-08-16T00:01Z", Date.from("2016-08-16T00:01").toString()) + } + + @Test + fun date_from_wack() { + assertEquals(null, Date.from(0)) + assertEquals(null, Date.from("")) + assertEquals(null, Date.from("2016-08-16:00:01:02")) + assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) + } + + @Test + fun dateRange_from_correct() { val range = requireNotNull( Date.Range.from( @@ -145,7 +103,7 @@ class DateTest { } @Test - fun dateRange_fromSingle() { + fun dateRange_from_one() { val range = requireNotNull( Date.Range.from(listOf(requireNotNull(Date.from("2016-08-16T00:01:02"))))) @@ -154,7 +112,7 @@ class DateTest { } @Test - fun dateRange_empty() { + fun dateRange_from_none() { assertEquals(null, Date.Range.from(listOf())) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt index a0a3096a6..946595096 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio.music +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.MusicDirectories interface FakeMusicSettings : MusicSettings { From 5adc87550e2e7305c4e4cd75f011b1dc45e98fc7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 7 Jan 2023 09:31:48 -0700 Subject: [PATCH 22/55] music: make package for auxillary music info Like the library package, move out tag information (Date/Album.Type) into a separate package. Date never really made sense as base-package information. --- .../oxycblt/auxio/detail/DetailViewModel.kt | 27 +-- .../detail/recycler/AlbumDetailAdapter.kt | 4 +- .../auxio/list/recycler/ViewHolders.kt | 2 +- .../java/org/oxycblt/auxio/music/Music.kt | 213 +----------------- .../auxio/music/extractor/CacheExtractor.kt | 18 +- .../music/extractor/MediaStoreExtractor.kt | 2 +- .../music/extractor/MetadataExtractor.kt | 6 +- .../org/oxycblt/auxio/music/library/Sort.kt | 1 + .../oxycblt/auxio/music/{ => tags}/Date.kt | 4 +- .../oxycblt/auxio/music/tags/ReleaseType.kt | 198 ++++++++++++++++ .../org/oxycblt/auxio/music/AlbumTypeTest.kt | 82 ------- .../auxio/music/library/LibraryTest.kt | 14 ++ .../auxio/music/{ => tags}/DateTest.kt | 2 +- .../auxio/music/tags/ReleaseTypeTest.kt | 82 +++++++ 14 files changed, 338 insertions(+), 317 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{ => tags}/Date.kt (99%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt rename app/src/test/java/org/oxycblt/auxio/music/{ => tags}/DateTest.kt (99%) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt 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 81282e354..ac009935e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* @@ -344,21 +345,21 @@ class DetailViewModel(application: Application) : val byReleaseGroup = albums.groupBy { - // Remap the complicated Album.Type data structure into an easier + // Remap the complicated ReleaseType data structure into an easier // "AlbumGrouping" enum that will automatically group and sort // the artist's albums. - when (it.type.refinement) { - Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE - Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES + when (it.releaseType.refinement) { + ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE + ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES null -> - when (it.type) { - is Album.Type.Album -> AlbumGrouping.ALBUMS - is Album.Type.EP -> AlbumGrouping.EPS - is Album.Type.Single -> AlbumGrouping.SINGLES - is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS - is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is Album.Type.Mix -> AlbumGrouping.MIXES - is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES + when (it.releaseType) { + is ReleaseType.Album -> AlbumGrouping.ALBUMS + is ReleaseType.EP -> AlbumGrouping.EPS + is ReleaseType.Single -> AlbumGrouping.SINGLES + is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS + is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS + is ReleaseType.Mix -> AlbumGrouping.MIXES + is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES } } } @@ -392,7 +393,7 @@ class DetailViewModel(application: Application) : } /** - * A simpler mapping of [Album.Type] used for grouping and sorting songs. + * A simpler mapping of [ReleaseType] used for grouping and sorting songs. * @param headerTitleRes The title string resource to use for a header created out of an * instance of this enum. */ 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 56a86cc6f..d6855c09f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -126,7 +126,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite binding.detailCover.bind(album) // The type text depends on the release type (Album, EP, Single, etc.) - binding.detailType.text = binding.context.getString(album.type.stringRes) + binding.detailType.text = binding.context.getString(album.releaseType.stringRes) binding.detailName.text = album.resolveName(binding.context) @@ -173,7 +173,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite oldItem.dates == newItem.dates && oldItem.songs.size == newItem.songs.size && oldItem.durationMs == newItem.durationMs && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 3aaac0609..75e570cf8 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -122,7 +122,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem) && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } 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 481a2116b..e4de7b1a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -34,6 +34,8 @@ import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.storage.* +import org.oxycblt.auxio.music.tags.Date +import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -463,7 +465,7 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, sortName = raw.albumSortName, - type = Album.Type.parse(raw.albumTypes.parseMultiValue(musicSettings)), + releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)), rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }) @@ -582,8 +584,8 @@ class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { var albumName: String? = null, /** @see Album.Raw.sortName */ var albumSortName: String? = null, - /** @see Album.Raw.type */ - var albumTypes: List = listOf(), + /** @see Album.Raw.releaseType */ + var releaseTypes: List = listOf(), /** @see Artist.Raw.musicBrainzId */ var artistMusicBrainzIds: List = listOf(), /** @see Artist.Raw.name */ @@ -629,10 +631,10 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( val dates = Date.Range.from(songs.mapNotNull { it.date }) /** - * The [Type] of this album, signifying the type of release it actually is. Defaults to - * [Type.Album]. + * The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to + * [ReleaseType.Album]. */ - val type = raw.type ?: Type.Album(null) + val releaseType = raw.releaseType ?: ReleaseType.Album(null) /** * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the * cost of image quality. @@ -727,201 +729,6 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( } } - /** - * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. - * - * This class is derived from the MusicBrainz Release Group Type specification. It can be found - * at: https://musicbrainz.org/doc/Release_Group/Type - * @author Alexander Capehart (OxygenCobalt) - */ - sealed class Type { - /** - * A specification of what kind of performance this release is. If null, the release is - * considered "Plain". - */ - abstract val refinement: Refinement? - - /** The string resource corresponding to the name of this release type to show in the UI. */ - abstract val stringRes: Int - - /** - * A plain album. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Album(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_album - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_album_live - Refinement.REMIX -> R.string.lbl_album_remix - } - } - - /** - * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class EP(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_ep - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_ep_live - Refinement.REMIX -> R.string.lbl_ep_remix - } - } - - /** - * A single. Usually a release consisting of 1-2 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Single(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_single - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_single_live - Refinement.REMIX -> R.string.lbl_single_remix - } - } - - /** - * A compilation. Usually consists of many songs from a variety of artists. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Compilation(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_compilation - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_compilation_live - Refinement.REMIX -> R.string.lbl_compilation_remix - } - } - - /** - * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually - * visual) media. - */ - object Soundtrack : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_soundtrack - } - - /** - * A (DJ) Mix. These are usually one large track consisting of the artist playing several - * sub-tracks with smooth transitions between them. - */ - object Mix : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mix - } - - /** - * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or - * a future release. - */ - object Mixtape : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mixtape - } - - /** A specification of what kind of performance a particular release is. */ - enum class Refinement { - /** A release consisting of a live performance */ - LIVE, - - /** A release consisting of another [Artist]s remix of a prior performance. */ - REMIX - } - - companion object { - /** - * Parse a [Type] from a string formatted with the MusicBrainz Release Group Type - * specification. - * @param types A list of values consisting of valid release type values. - * @return A [Type] consisting of the given types, or null if the types were not valid. - */ - fun parse(types: List): Type? { - val primary = types.getOrNull(0) ?: return null - return when { - // Primary types should be the first types in the sequence. - 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) } - // The spec makes no mention of whether primary types are a pre-requisite for - // secondary types, so we assume that it's not and map oprhan secondary types - // to Album release types. - else -> types.parseSecondaryTypes(0) { Album(it) } - } - } - - /** - * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted - * with the MusicBrainz Release Group Type specification. - * @param index The index of the release type to parse. - * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. - * @return A [Type] corresponding to the secondary type found at that index. - */ - private inline fun List.parseSecondaryTypes( - index: Int, - convertRefinement: (Refinement?) -> Type - ): Type { - val secondary = getOrNull(index) - 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(index + 1)) { Compilation(it) } - } else { - // Secondary type is a plain value, use the original values given. - parseSecondaryTypeImpl(secondary, convertRefinement) - } - } - - /** - * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to - * any child values. - * @param type The release type value to parse. - * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. - */ - private inline fun parseSecondaryTypeImpl( - type: String?, - convertRefinement: (Refinement?) -> Type - ) = - 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) - } - } - } - /** * Raw information about an [Album] obtained from the component [Song] instances. **This is only * meant for use within the music package.** @@ -938,8 +745,8 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( val name: String, /** @see Music.rawSortName */ val sortName: String?, - /** @see Album.type */ - val type: Type?, + /** @see Album.releaseType */ + val releaseType: ReleaseType?, /** @see Artist.Raw.name */ val rawArtists: List ) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt index 10dc6ed72..99115f755 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -23,7 +23,7 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.music.Date +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.splitEscaped @@ -142,7 +142,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId rawSong.albumName = cachedRawSong.albumName rawSong.albumSortName = cachedRawSong.albumSortName - rawSong.albumTypes = cachedRawSong.albumTypes + rawSong.releaseTypes = cachedRawSong.releaseTypes rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds rawSong.artistNames = cachedRawSong.artistNames @@ -190,7 +190,7 @@ private class CacheDatabase(context: Context) : append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,") append("${Columns.ALBUM_NAME} STRING NOT NULL,") append("${Columns.ALBUM_SORT_NAME} STRING,") - append("${Columns.ALBUM_TYPES} STRING,") + append("${Columns.RELEASE_TYPES} STRING,") append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,") append("${Columns.ARTIST_NAMES} STRING,") append("${Columns.ARTIST_SORT_NAMES} STRING,") @@ -249,7 +249,7 @@ private class CacheDatabase(context: Context) : cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID) val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME) val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME) - val albumTypesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_TYPES) + val releaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.RELEASE_TYPES) val artistMusicBrainzIdsIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS) @@ -286,8 +286,8 @@ private class CacheDatabase(context: Context) : raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex) raw.albumName = cursor.getString(albumNameIndex) raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex) - cursor.getStringOrNull(albumTypesIndex)?.let { - raw.albumTypes = it.parseSQLMultiValue() + cursor.getStringOrNull(releaseTypesIndex)?.let { + raw.releaseTypes = it.parseSQLMultiValue() } cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let { @@ -351,7 +351,7 @@ private class CacheDatabase(context: Context) : put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId) put(Columns.ALBUM_NAME, rawSong.albumName) put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName) - put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue()) + put(Columns.RELEASE_TYPES, rawSong.releaseTypes.toSQLMultiValue()) put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue()) put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue()) @@ -422,8 +422,8 @@ private class CacheDatabase(context: Context) : const val ALBUM_NAME = "album" /** @see Song.Raw.albumSortName */ const val ALBUM_SORT_NAME = "album_sort" - /** @see Song.Raw.albumTypes */ - const val ALBUM_TYPES = "album_types" + /** @see Song.Raw.releaseTypes */ + const val RELEASE_TYPES = "album_types" /** @see Song.Raw.artistMusicBrainzIds */ const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid" /** @see Song.Raw.artistNames */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 62b983672..296c0595c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -27,7 +27,7 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File -import org.oxycblt.auxio.music.Date +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position 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 f4a203778..c86267206 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 @@ -22,7 +22,7 @@ import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever import kotlinx.coroutines.flow.flow -import org.oxycblt.auxio.music.Date +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.music.storage.toAudioUri @@ -208,7 +208,7 @@ class Task(context: Context, private val raw: Song.Raw) { textFrames["TALB"]?.let { raw.albumName = it[0] } textFrames["TSOA"]?.let { raw.albumSortName = it[0] } (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { - raw.albumTypes = it + raw.releaseTypes = it } // Artist @@ -300,7 +300,7 @@ class Task(context: Context, private val raw: Song.Raw) { comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } comments["album"]?.let { raw.albumName = it[0] } comments["albumsort"]?.let { raw.albumSortName = it[0] } - comments["releasetype"]?.let { raw.albumTypes = it } + comments["releasetype"]?.let { raw.releaseTypes = it } // Artist comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt index 01d57a57d..a126f3879 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt @@ -23,6 +23,7 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.library.Sort.Mode +import org.oxycblt.auxio.music.tags.Date /** * A sorting method. diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/Date.kt rename to app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt index ad815a779..1f1340de8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.tags import android.content.Context import java.text.ParseException @@ -235,7 +235,7 @@ class Date private constructor(private val tokens: List) : Comparable val tokens = // Match the input with the timestamp regex. If there is no match, see if we can // fall back to some kind of year value. - (ISO8601_REGEX.matchEntire(timestamp) ?: return timestamp.toIntOrNull()?.let(::from)) + (ISO8601_REGEX.matchEntire(timestamp) ?: return timestamp.toIntOrNull()?.let(Companion::from)) .groupValues // Filter to the specific tokens we want and convert them to integer tokens. .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt new file mode 100644 index 000000000..fd9567154 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt @@ -0,0 +1,198 @@ +package org.oxycblt.auxio.music.tags + +import org.oxycblt.auxio.R + +/** + * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. + * + * This class is derived from the MusicBrainz Release Group Type specification. It can be found + * at: https://musicbrainz.org/doc/Release_Group/Type + * @author Alexander Capehart (OxygenCobalt) + */ +sealed class ReleaseType { + /** + * A specification of what kind of performance this release is. If null, the release is + * considered "Plain". + */ + abstract val refinement: Refinement? + + /** The string resource corresponding to the name of this release type to show in the UI. */ + abstract val stringRes: Int + + /** + * A plain album. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Album(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_album + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_album_live + Refinement.REMIX -> R.string.lbl_album_remix + } + } + + /** + * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class EP(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_ep + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_ep_live + Refinement.REMIX -> R.string.lbl_ep_remix + } + } + + /** + * A single. Usually a release consisting of 1-2 songs. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Single(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_single + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_single_live + Refinement.REMIX -> R.string.lbl_single_remix + } + } + + /** + * A compilation. Usually consists of many songs from a variety of artists. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Compilation(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_compilation + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_compilation_live + Refinement.REMIX -> R.string.lbl_compilation_remix + } + } + + /** + * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually + * visual) media. + */ + object Soundtrack : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_soundtrack + } + + /** + * A (DJ) Mix. These are usually one large track consisting of the artist playing several + * sub-tracks with smooth transitions between them. + */ + object Mix : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mix + } + + /** + * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or + * a future release. + */ + object Mixtape : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mixtape + } + + /** A specification of what kind of performance a particular release is. */ + enum class Refinement { + /** A release consisting of a live performance */ + LIVE, + + /** A release consisting of another [Artist]s remix of a prior performance. */ + REMIX + } + + companion object { + /** + * Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type + * specification. + * @param types A list of values consisting of valid release type values. + * @return A [ReleaseType] consisting of the given types, or null if the types were not valid. + */ + fun parse(types: List): ReleaseType? { + val primary = types.getOrNull(0) ?: return null + return when { + // Primary types should be the first types in the sequence. + 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) } + // The spec makes no mention of whether primary types are a pre-requisite for + // secondary types, so we assume that it's not and map oprhan secondary types + // to Album release types. + else -> types.parseSecondaryTypes(0) { Album(it) } + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted + * with the MusicBrainz Release Group Type specification. + * @param index The index of the release type to parse. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] corresponding + * to the callee's context. This is used in order to handle secondary times that are + * actually [Refinement]s. + * @return A [ReleaseType] corresponding to the secondary type found at that index. + */ + private inline fun List.parseSecondaryTypes( + index: Int, + convertRefinement: (Refinement?) -> ReleaseType + ): ReleaseType { + val secondary = getOrNull(index) + 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(index + 1)) { Compilation(it) } + } else { + // Secondary type is a plain value, use the original values given. + parseSecondaryTypeImpl(secondary, convertRefinement) + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to + * any child values. + * @param type The release type value to parse. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] corresponding + * to the callee's context. This is used in order to handle secondary times that are + * actually [Refinement]s. + */ + 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) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt deleted file mode 100644 index ea4581f54..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/AlbumTypeTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import org.junit.Assert.assertEquals -import org.junit.Test - -class AlbumTypeTest { - @Test - fun albumType_parse_primary() { - assertEquals(Album.Type.Album(null), Album.Type.parse(listOf("album"))) - assertEquals(Album.Type.EP(null), Album.Type.parse(listOf("ep"))) - assertEquals(Album.Type.Single(null), Album.Type.parse(listOf("single"))) - } - - @Test - fun albumType_parse_secondary() { - assertEquals(Album.Type.Compilation(null), Album.Type.parse(listOf("album", "compilation"))) - assertEquals(Album.Type.Soundtrack, Album.Type.parse(listOf("album", "soundtrack"))) - assertEquals(Album.Type.Mix, Album.Type.parse(listOf("album", "dj-mix"))) - assertEquals(Album.Type.Mixtape, Album.Type.parse(listOf("album", "mixtape/street"))) - } - - @Test - fun albumType_parse_modifiers() { - assertEquals( - Album.Type.Album(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("album", "live"))) - assertEquals( - Album.Type.Album(Album.Type.Refinement.REMIX), - Album.Type.parse(listOf("album", "remix"))) - assertEquals( - Album.Type.EP(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("ep", "live"))) - assertEquals( - Album.Type.EP(Album.Type.Refinement.REMIX), Album.Type.parse(listOf("ep", "remix"))) - assertEquals( - Album.Type.Single(Album.Type.Refinement.LIVE), - Album.Type.parse(listOf("single", "live"))) - assertEquals( - Album.Type.Single(Album.Type.Refinement.REMIX), - Album.Type.parse(listOf("single", "remix"))) - } - - @Test - fun albumType_parse_secondaryModifiers() { - assertEquals( - Album.Type.Compilation(Album.Type.Refinement.LIVE), - Album.Type.parse(listOf("album", "compilation", "live"))) - assertEquals( - Album.Type.Compilation(Album.Type.Refinement.REMIX), - Album.Type.parse(listOf("album", "compilation", "remix"))) - } - - @Test - fun albumType_parse_orphanedSecondary() { - assertEquals(Album.Type.Compilation(null), Album.Type.parse(listOf("compilation"))) - assertEquals(Album.Type.Soundtrack, Album.Type.parse(listOf("soundtrack"))) - assertEquals(Album.Type.Mix, Album.Type.parse(listOf("dj-mix"))) - assertEquals(Album.Type.Mixtape, Album.Type.parse(listOf("mixtape/street"))) - } - - @Test - fun albumType_parse_orphanedModifier() { - assertEquals(Album.Type.Album(Album.Type.Refinement.LIVE), Album.Type.parse(listOf("live"))) - assertEquals( - Album.Type.Album(Album.Type.Refinement.REMIX), Album.Type.parse(listOf("remix"))) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt new file mode 100644 index 000000000..8d4ab0f15 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt @@ -0,0 +1,14 @@ +package org.oxycblt.auxio.music.library + +import org.oxycblt.auxio.music.Song + +class LibraryTest { + + companion object { + val LIBRARY = listOf( + Song.Raw( + + ) + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/tags/DateTest.kt similarity index 99% rename from app/src/test/java/org/oxycblt/auxio/music/DateTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/tags/DateTest.kt index bd21969fc..658b37e97 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/tags/DateTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.tags import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt new file mode 100644 index 000000000..63999edf3 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.tags + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReleaseTypeTest { + @Test + fun releaseType_parse_primary() { + assertEquals(ReleaseType.Album(null), ReleaseType.parse(listOf("album"))) + assertEquals(ReleaseType.EP(null), ReleaseType.parse(listOf("ep"))) + assertEquals(ReleaseType.Single(null), ReleaseType.parse(listOf("single"))) + } + + @Test + fun releaseType_parse_secondary() { + assertEquals(ReleaseType.Compilation(null), ReleaseType.parse(listOf("album", "compilation"))) + assertEquals(ReleaseType.Soundtrack, ReleaseType.parse(listOf("album", "soundtrack"))) + assertEquals(ReleaseType.Mix, ReleaseType.parse(listOf("album", "dj-mix"))) + assertEquals(ReleaseType.Mixtape, ReleaseType.parse(listOf("album", "mixtape/street"))) + } + + @Test + fun releaseType_parse_modifiers() { + assertEquals( + ReleaseType.Album(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("album", "live"))) + assertEquals( + ReleaseType.Album(ReleaseType.Refinement.REMIX), + ReleaseType.parse(listOf("album", "remix"))) + assertEquals( + ReleaseType.EP(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("ep", "live"))) + assertEquals( + ReleaseType.EP(ReleaseType.Refinement.REMIX), ReleaseType.parse(listOf("ep", "remix"))) + assertEquals( + ReleaseType.Single(ReleaseType.Refinement.LIVE), + ReleaseType.parse(listOf("single", "live"))) + assertEquals( + ReleaseType.Single(ReleaseType.Refinement.REMIX), + ReleaseType.parse(listOf("single", "remix"))) + } + + @Test + fun releaseType_parse_secondaryModifiers() { + assertEquals( + ReleaseType.Compilation(ReleaseType.Refinement.LIVE), + ReleaseType.parse(listOf("album", "compilation", "live"))) + assertEquals( + ReleaseType.Compilation(ReleaseType.Refinement.REMIX), + ReleaseType.parse(listOf("album", "compilation", "remix"))) + } + + @Test + fun releaseType_parse_orphanedSecondary() { + assertEquals(ReleaseType.Compilation(null), ReleaseType.parse(listOf("compilation"))) + assertEquals(ReleaseType.Soundtrack, ReleaseType.parse(listOf("soundtrack"))) + assertEquals(ReleaseType.Mix, ReleaseType.parse(listOf("dj-mix"))) + assertEquals(ReleaseType.Mixtape, ReleaseType.parse(listOf("mixtape/street"))) + } + + @Test + fun releaseType_parse_orphanedModifier() { + assertEquals(ReleaseType.Album(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("live"))) + assertEquals( + ReleaseType.Album(ReleaseType.Refinement.REMIX), ReleaseType.parse(listOf("remix"))) + } +} From bef4dca0cea8137e3553749e2633366e15bb8baf Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 7 Jan 2023 12:00:53 -0700 Subject: [PATCH 23/55] playback: fix queue moves Fix moving items with the new queue system. This took a bit of thinking, but I think this is the correct way to implement this in a future-proof manner. --- .../auxio/music/extractor/CacheExtractor.kt | 2 +- .../music/extractor/MediaStoreExtractor.kt | 2 +- .../music/extractor/MetadataExtractor.kt | 2 +- .../java/org/oxycblt/auxio/music/tags/Date.kt | 3 +- .../oxycblt/auxio/music/tags/ReleaseType.kt | 66 ++++++++++++------- .../org/oxycblt/auxio/playback/state/Queue.kt | 34 +++++++--- .../auxio/music/library/LibraryTest.kt | 25 +++++-- .../auxio/music/tags/ReleaseTypeTest.kt | 9 ++- 8 files changed, 96 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt index 99115f755..2aa5e8e2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -23,10 +23,10 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.splitEscaped +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.* /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 296c0595c..0145222aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -27,7 +27,6 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File -import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position @@ -38,6 +37,7 @@ import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.storage.safeQuery import org.oxycblt.auxio.music.storage.storageVolumesCompat import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull 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 c86267206..7910168f1 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 @@ -22,10 +22,10 @@ import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever import kotlinx.coroutines.flow.flow -import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.parseId3v2Position import org.oxycblt.auxio.music.storage.toAudioUri +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt index 1f1340de8..1c68fa05e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt @@ -235,7 +235,8 @@ class Date private constructor(private val tokens: List) : Comparable val tokens = // Match the input with the timestamp regex. If there is no match, see if we can // fall back to some kind of year value. - (ISO8601_REGEX.matchEntire(timestamp) ?: return timestamp.toIntOrNull()?.let(Companion::from)) + (ISO8601_REGEX.matchEntire(timestamp) + ?: return timestamp.toIntOrNull()?.let(Companion::from)) .groupValues // Filter to the specific tokens we want and convert them to integer tokens. .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt index fd9567154..3331fda7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt @@ -1,3 +1,20 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.music.tags import org.oxycblt.auxio.R @@ -5,8 +22,8 @@ import org.oxycblt.auxio.R /** * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. * - * This class is derived from the MusicBrainz Release Group Type specification. It can be found - * at: https://musicbrainz.org/doc/Release_Group/Type + * This class is derived from the MusicBrainz Release Group Type specification. It can be found at: + * https://musicbrainz.org/doc/Release_Group/Type * @author Alexander Capehart (OxygenCobalt) */ sealed class ReleaseType { @@ -21,8 +38,8 @@ sealed class ReleaseType { /** * A plain album. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". */ data class Album(override val refinement: Refinement?) : ReleaseType() { override val stringRes: Int @@ -37,8 +54,8 @@ sealed class ReleaseType { /** * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". */ data class EP(override val refinement: Refinement?) : ReleaseType() { override val stringRes: Int @@ -53,8 +70,8 @@ sealed class ReleaseType { /** * A single. Usually a release consisting of 1-2 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". */ data class Single(override val refinement: Refinement?) : ReleaseType() { override val stringRes: Int @@ -69,8 +86,8 @@ sealed class ReleaseType { /** * A compilation. Usually consists of many songs from a variety of artists. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". */ data class Compilation(override val refinement: Refinement?) : ReleaseType() { override val stringRes: Int @@ -108,8 +125,8 @@ sealed class ReleaseType { } /** - * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or - * a future release. + * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or a + * future release. */ object Mixtape : ReleaseType() { override val refinement: Refinement? @@ -133,7 +150,8 @@ sealed class ReleaseType { * Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type * specification. * @param types A list of values consisting of valid release type values. - * @return A [ReleaseType] consisting of the given types, or null if the types were not valid. + * @return A [ReleaseType] consisting of the given types, or null if the types were not + * valid. */ fun parse(types: List): ReleaseType? { val primary = types.getOrNull(0) ?: return null @@ -150,12 +168,12 @@ sealed class ReleaseType { } /** - * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted - * with the MusicBrainz Release Group Type specification. + * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with + * the MusicBrainz Release Group Type specification. * @param index The index of the release type to parse. - * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] + * corresponding to the callee's context. This is used in order to handle secondary times + * that are actually [Refinement]s. * @return A [ReleaseType] corresponding to the secondary type found at that index. */ private inline fun List.parseSecondaryTypes( @@ -174,12 +192,12 @@ sealed class ReleaseType { } /** - * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to - * any child values. + * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any + * child values. * @param type The release type value to parse. - * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] + * corresponding to the callee's context. This is used in order to handle secondary times + * that are actually [Refinement]s. */ private inline fun parseSecondaryTypeImpl( type: String?, @@ -195,4 +213,4 @@ sealed class ReleaseType { else -> convertRefinement(null) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 045bb4110..2d0388470 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -112,6 +112,21 @@ class Queue { } } + /** + * Reformat the queue's internal representation to align with the given values. This is not + * useful in most circumstances. + * @param + */ + fun rework(heap: List, orderedMapping: IntArray, shuffledMapping: IntArray) { + // val instructions = mutableListOf() + // val currentBackshift = 0 + // for (song in heap) { + // if (song == null) { + // instructions.add(0, ) + // } + // } + } + /** * Add [Song]s to the top of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. @@ -179,18 +194,17 @@ class Queue { orderedMapping.add(dst, orderedMapping.removeAt(src)) } - // TODO: I really need to figure out how to get non-swap moves working. - return when (index) { - src -> { - index = dst - ChangeResult.INDEX - } - dst -> { - index = src - ChangeResult.INDEX - } + when (index) { + // We are moving the currently playing song, correct the index to it's new position. + src -> index = dst + // We have moved an song from behind the playing song to in front, shift back. + in (src + 1)..dst -> index -= 1 + // We have moved an song from in front of the playing song to behind, shift forward. + in dst until src -> index += 1 else -> ChangeResult.MAPPING } + + return ChangeResult.INDEX } /** diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt index 8d4ab0f15..6368a32e1 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt @@ -1,3 +1,20 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.music.library import org.oxycblt.auxio.music.Song @@ -5,10 +22,6 @@ import org.oxycblt.auxio.music.Song class LibraryTest { companion object { - val LIBRARY = listOf( - Song.Raw( - - ) - ) + val LIBRARY = listOf(Song.Raw()) } -} \ No newline at end of file +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt index 63999edf3..6187fbb0e 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/tags/ReleaseTypeTest.kt @@ -30,7 +30,8 @@ class ReleaseTypeTest { @Test fun releaseType_parse_secondary() { - assertEquals(ReleaseType.Compilation(null), ReleaseType.parse(listOf("album", "compilation"))) + assertEquals( + ReleaseType.Compilation(null), ReleaseType.parse(listOf("album", "compilation"))) assertEquals(ReleaseType.Soundtrack, ReleaseType.parse(listOf("album", "soundtrack"))) assertEquals(ReleaseType.Mix, ReleaseType.parse(listOf("album", "dj-mix"))) assertEquals(ReleaseType.Mixtape, ReleaseType.parse(listOf("album", "mixtape/street"))) @@ -39,7 +40,8 @@ class ReleaseTypeTest { @Test fun releaseType_parse_modifiers() { assertEquals( - ReleaseType.Album(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("album", "live"))) + ReleaseType.Album(ReleaseType.Refinement.LIVE), + ReleaseType.parse(listOf("album", "live"))) assertEquals( ReleaseType.Album(ReleaseType.Refinement.REMIX), ReleaseType.parse(listOf("album", "remix"))) @@ -75,7 +77,8 @@ class ReleaseTypeTest { @Test fun releaseType_parse_orphanedModifier() { - assertEquals(ReleaseType.Album(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("live"))) + assertEquals( + ReleaseType.Album(ReleaseType.Refinement.LIVE), ReleaseType.parse(listOf("live"))) assertEquals( ReleaseType.Album(ReleaseType.Refinement.REMIX), ReleaseType.parse(listOf("remix"))) } From 82a9c086664c897f1fa99f3dc1bb723e493638de Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 7 Jan 2023 15:45:55 -0700 Subject: [PATCH 24/55] playback: re-add queue sanitization Add library-change sanitization to the queue. It is hard to describe how unbeliveably difficult this was. It's so hard to wrap your head around this system and I really would have never used it if it was not for ExoPlayer's insistence on it's busted ShuffleOrder code. Re-enabling state persistence should be easier following this. --- .../playback/state/PlaybackStateManager.kt | 80 +++++------ .../org/oxycblt/auxio/playback/state/Queue.kt | 128 +++++++++++++----- 2 files changed, 132 insertions(+), 76 deletions(-) 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 9114450fc..67b596edf 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 @@ -494,50 +494,42 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun sanitize(newLibrary: Library) { - // if (!isInitialized) { - // // Nothing playing, nothing to do. - // logD("Not initialized, no need to sanitize") - // return - // } - // - // val internalPlayer = internalPlayer ?: return - // - // logD("Sanitizing state") - // - // // 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). - // - // // Sanitize parent - // parent = - // parent?.let { - // when (it) { - // is Album -> newLibrary.sanitize(it) - // is Artist -> newLibrary.sanitize(it) - // is Genre -> newLibrary.sanitize(it) - // } - // } - // - // // Sanitize queue. Make sure we re-align the index to point to the previously - // playing - // // Song in the queue queue. - // val oldSongUid = song?.uid - // _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) } - // while (song?.uid != oldSongUid && index > -1) { - // index-- - // } - // - // notifyNewPlayback() - // - // val oldPosition = playerState.calculateElapsedPositionMs() - // // Continuing playback while also possibly doing drastic state updates is - // // a bad idea, so pause. - // internalPlayer.loadSong(song, false) - // if (index > -1) { - // // Internal player may have reloaded the media item, re-seek to the previous - // position - // seekTo(oldPosition) - // } + if (!isInitialized) { + // Nothing playing, nothing to do. + logD("Not initialized, no need to sanitize") + return + } + + val internalPlayer = internalPlayer ?: return + + logD("Sanitizing state") + + // 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). + + // Sanitize parent + parent = + parent?.let { + when (it) { + is Album -> newLibrary.sanitize(it) + is Artist -> newLibrary.sanitize(it) + is Genre -> newLibrary.sanitize(it) + } + } + + // Sanitize the queue. + queue.remap { it.map(newLibrary::sanitize) } + + notifyNewPlayback() + + val oldPosition = playerState.calculateElapsedPositionMs() + // Continuing playback while also possibly doing drastic state updates is + // a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { + // Internal player may have reloaded the media item, re-seek to the previous position + seekTo(oldPosition) + } } // --- CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 2d0388470..de48736a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -29,8 +29,9 @@ import org.oxycblt.auxio.music.Song * implementation is instead based around an unorganized "heap" of [Song] instances, that are then * interpreted into different queues depending on the current playback configuration. * - * In general, the implementation details don't need ot be known for this data structure to be used. - * The functions exposed should be familiar for any typical play queue. + * In general, the implementation details don't need to be known for this data structure to be used, + * except in special circumstances like [remap]. The functions exposed should be familiar for any + * typical play queue. * * @author OxygenCobalt */ @@ -40,11 +41,15 @@ class Queue { @Volatile private var shuffledMapping = mutableListOf() /** The index of the currently playing [Song] in the current mapping. */ @Volatile - var index = 0 + var index = -1 private set /** The currently playing [Song]. */ val currentSong: Song? - get() = shuffledMapping.ifEmpty { orderedMapping.ifEmpty { null } }?.let { heap[it[index]] } + get() = + shuffledMapping + .ifEmpty { orderedMapping.ifEmpty { null } } + ?.getOrNull(index) + ?.let(heap::get) /** Whether this queue is shuffled. */ val isShuffled: Boolean get() = shuffledMapping.isNotEmpty() @@ -70,19 +75,20 @@ class Queue { /** * Start a new queue configuration. - * @param song The [Song] to play, or null to start from a random position. - * @param queue The queue of [Song]s to play. Must contain [song]. This list will become the + * @param play The [Song] to play, or null to start from a random position. + * @param queue The queue of [Song]s to play. Must contain [play]. This list will become the * heap internally. * @param shuffled Whether to shuffle the queue or not. This changes the interpretation of * [queue]. */ - fun start(song: Song?, queue: List, shuffled: Boolean) { + fun start(play: Song?, queue: List, shuffled: Boolean) { heap = queue.toMutableList() orderedMapping = MutableList(queue.size) { it } shuffledMapping = mutableListOf() index = - song?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0 + play?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0 reorder(shuffled) + check() } /** @@ -90,6 +96,11 @@ class Queue { * @param shuffled Whether the queue should be shuffled or not. */ fun reorder(shuffled: Boolean) { + if (orderedMapping.isEmpty()) { + // Nothing to do. + return + } + if (shuffled) { val trueIndex = if (shuffledMapping.isNotEmpty()) { @@ -110,21 +121,47 @@ class Queue { index = orderedMapping.indexOf(shuffledMapping[index]) shuffledMapping = mutableListOf() } + check() } /** - * Reformat the queue's internal representation to align with the given values. This is not - * useful in most circumstances. - * @param + * Replace the given heap with a new + * @param map Code to remap the existing [Song] heap into a new [Song] heap. This **MUST** be + * the same size as the original heap. [Song] instances that could not be converted should be + * replaced with null in the new heap. + * @throws IllegalStateException If the given invariants regarding [map] were violated. */ - fun rework(heap: List, orderedMapping: IntArray, shuffledMapping: IntArray) { - // val instructions = mutableListOf() - // val currentBackshift = 0 - // for (song in heap) { - // if (song == null) { - // instructions.add(0, ) - // } - // } + fun remap(map: (List) -> List) { + val newHeap = map(heap) + val oldSong = currentSong + check(newHeap.size == heap.size) { "New heap must be the same size as original heap" } + + val adjustments = mutableListOf() + var currentShift = 0 + for (song in newHeap) { + if (song != null) { + adjustments.add(currentShift) + } else { + adjustments.add(null) + currentShift -= 1 + } + } + + heap = newHeap.filterNotNull().toMutableList() + orderedMapping = + orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + shuffledMapping = + shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + + // Make sure we re-align the index to point to the previously playing song. + while (currentSong != oldSong && index > -1) { + index-- + } + check() } /** @@ -151,6 +188,7 @@ class Queue { // Add the new song in front of the current index in the ordered mapping. orderedMapping.addAll(index + 1, heapIndices) } + check() return ChangeResult.MAPPING } @@ -173,6 +211,7 @@ class Queue { if (shuffledMapping.isNotEmpty()) { shuffledMapping.addAll(heapIndices) } + check() return ChangeResult.MAPPING } @@ -201,9 +240,12 @@ class Queue { in (src + 1)..dst -> index -= 1 // We have moved an song from in front of the playing song to behind, shift forward. in dst until src -> index += 1 - else -> ChangeResult.MAPPING + else -> { + check() + ChangeResult.MAPPING + } } - + check() return ChangeResult.INDEX } @@ -229,17 +271,20 @@ class Queue { // of the player to be completely invalidated. It's generally easier to not remove the // song and retain player state consistency. - return when { - // We just removed the currently playing song. - index == at -> ChangeResult.SONG - // Index was ahead of removed song, shift back to preserve consistency. - index > at -> { - index -= 1 - ChangeResult.INDEX + val result = + when { + // We just removed the currently playing song. + index == at -> ChangeResult.SONG + // Index was ahead of removed song, shift back to preserve consistency. + index > at -> { + index -= 1 + ChangeResult.INDEX + } + // Nothing to do + else -> ChangeResult.MAPPING } - // Nothing to do - else -> ChangeResult.MAPPING - } + check() + return result } private fun addSongToHeap(song: Song): Int { @@ -264,12 +309,31 @@ class Queue { return orphanCandidates.first() } } - // Nothing to re-use, add this song to the queue heap.add(song) return heap.lastIndex } + private fun check() { + check(!(heap.isEmpty() && (orderedMapping.isNotEmpty() || shuffledMapping.isNotEmpty()))) { + "Queue inconsistency detected: Empty heap with non-empty mappings" + + "[ordered: ${orderedMapping.size}, shuffled: ${shuffledMapping.size}]" + } + + check(shuffledMapping.isEmpty() || orderedMapping.size == shuffledMapping.size) { + "Queue inconsistency detected: Ordered mapping size ${orderedMapping.size} " + + "!= Shuffled mapping size ${shuffledMapping.size}" + } + + check(orderedMapping.all { it in heap.indices }) { + "Queue inconsistency detected: Ordered mapping indices out of heap bounds" + } + + check(shuffledMapping.all { it in heap.indices }) { + "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" + } + } + /** * Represents the possible changes that can occur during certain queue mutation events. The * precise meanings of these differ somewhat depending on the type of mutation done. From 0199d2f343bbb87acadeb7e2c49185ff96d91f13 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 8 Jan 2023 09:52:56 -0700 Subject: [PATCH 25/55] playback: refactor queue persistence Refactor the queue saved state system to make it easier to imlement in the playback state database. --- .../java/org/oxycblt/auxio/music/tags/Date.kt | 2 - .../playback/state/PlaybackStateManager.kt | 4 +- .../org/oxycblt/auxio/playback/state/Queue.kt | 121 ++++++++++++------ .../auxio/music/library/LibraryTest.kt | 13 +- 4 files changed, 90 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt index 1c68fa05e..d3658ce6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt @@ -273,7 +273,5 @@ class Date private constructor(private val tokens: List) : Comparable dst.add(src.getOrNull(4)?.inRangeOrNull(0..59) ?: return) dst.add(src.getOrNull(5)?.inRangeOrNull(0..59) ?: return) } - - private fun transformYearToken(src: List, dst: MutableList) {} } } 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 67b596edf..efa375415 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 @@ -28,6 +28,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Core playback state controller class. @@ -518,7 +519,8 @@ class PlaybackStateManager private constructor() { } // Sanitize the queue. - queue.remap { it.map(newLibrary::sanitize) } + queue.applySavedState( + queue.toSavedState().remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) notifyNewPlayback() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index de48736a2..36655d543 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.playback.state import kotlin.random.Random import kotlin.random.nextInt +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song /** @@ -30,8 +31,8 @@ import org.oxycblt.auxio.music.Song * interpreted into different queues depending on the current playback configuration. * * In general, the implementation details don't need to be known for this data structure to be used, - * except in special circumstances like [remap]. The functions exposed should be familiar for any - * typical play queue. + * except in special circumstances like [SavedState]. The functions exposed should be familiar for + * any typical play queue. * * @author OxygenCobalt */ @@ -124,46 +125,6 @@ class Queue { check() } - /** - * Replace the given heap with a new - * @param map Code to remap the existing [Song] heap into a new [Song] heap. This **MUST** be - * the same size as the original heap. [Song] instances that could not be converted should be - * replaced with null in the new heap. - * @throws IllegalStateException If the given invariants regarding [map] were violated. - */ - fun remap(map: (List) -> List) { - val newHeap = map(heap) - val oldSong = currentSong - check(newHeap.size == heap.size) { "New heap must be the same size as original heap" } - - val adjustments = mutableListOf() - var currentShift = 0 - for (song in newHeap) { - if (song != null) { - adjustments.add(currentShift) - } else { - adjustments.add(null) - currentShift -= 1 - } - } - - heap = newHeap.filterNotNull().toMutableList() - orderedMapping = - orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> - adjustments[heapIndex]?.let { heapIndex + it } - } - shuffledMapping = - shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex -> - adjustments[heapIndex]?.let { heapIndex + it } - } - - // Make sure we re-align the index to point to the previously playing song. - while (currentSong != oldSong && index > -1) { - index-- - } - check() - } - /** * Add [Song]s to the top of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. @@ -287,6 +248,52 @@ class Queue { return result } + /** + * Convert the current state of this instance into a [SavedState]. + * @return A new [SavedState] reflecting the exact state of the queue when called. + */ + fun toSavedState() = + SavedState( + heap.toList(), + orderedMapping.toList(), + shuffledMapping.toList(), + index, + currentSong?.uid) + + /** + * Update this instance from the given [SavedState]. + * @param savedState A [SavedState] with a valid queue representation. + */ + fun applySavedState(savedState: SavedState) { + val adjustments = mutableListOf() + var currentShift = 0 + for (song in savedState.heap) { + if (song != null) { + adjustments.add(currentShift) + } else { + adjustments.add(null) + currentShift -= 1 + } + } + + heap = savedState.heap.filterNotNull().toMutableList() + orderedMapping = + savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + shuffledMapping = + savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + + // Make sure we re-align the index to point to the previously playing song. + index = savedState.currentIndex + while (currentSong?.uid != savedState.currentSongUid && index > -1) { + index-- + } + check() + } + private fun addSongToHeap(song: Song): Int { // We want to first try to see if there are any "orphaned" songs in the queue // that we can re-use. This way, we can reduce the memory used up by songs that @@ -334,6 +341,36 @@ class Queue { } } + /** + * An immutable representation of the queue state. + * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with + * null values to represent [Song]s that were "lost" from the heap without having to change + * other values. + * @param orderedMapping The mapping of the [heap] to an ordered queue. + * @param shuffledMapping The mapping of the [heap] to a shuffled queue. + * @param currentIndex The index of the currently playing [Song] at the time of serialization. + * @param currentSongUid The [Music.UID] of the [Song] that was originally at [currentIndex]. + */ + class SavedState( + val heap: List, + val orderedMapping: List, + val shuffledMapping: List, + val currentIndex: Int, + val currentSongUid: Music.UID?, + ) { + /** + * Remaps the [heap] of this instance based on the given mapping function and copies it into + * a new [SavedState]. + * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This + * **MUST** be the same size as the original heap. [Song] instances that could not be + * converted should be replaced with null in the new heap. + * @throws IllegalStateException If the invariant specified by [transform] is violated. + */ + inline fun remap(transform: (Song?) -> Song?) = + SavedState( + heap.map(transform), orderedMapping, shuffledMapping, currentIndex, currentSongUid) + } + /** * Represents the possible changes that can occur during certain queue mutation events. The * precise meanings of these differ somewhat depending on the type of mutation done. diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt index 6368a32e1..42263b2bb 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/library/LibraryTest.kt @@ -17,11 +17,14 @@ package org.oxycblt.auxio.music.library -import org.oxycblt.auxio.music.Song - class LibraryTest { + fun library_common() {} - companion object { - val LIBRARY = listOf(Song.Raw()) - } + fun library_sparse() {} + + fun library_multiArtist() {} + + fun library_multiGenre() {} + + fun library_musicBrainz() {} } From 9bd78bc8559f63892d4c6cb5370ad27e85e91e05 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 8 Jan 2023 10:31:28 -0700 Subject: [PATCH 26/55] music: add uid and raw tests Add tests for the relatively simple Music.UID and Raw data classes (excluding Song.Raw). This should make sure most of the grouping code works, making the library tests theoretically much simpler. --- .../java/org/oxycblt/auxio/music/Music.kt | 62 ++--- .../java/org/oxycblt/auxio/music/MusicTest.kt | 213 ++++++++++++++++++ 2 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicTest.kt 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 e4de7b1a2..39aeab02d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music import android.content.Context import android.os.Parcelable +import androidx.annotation.VisibleForTesting import java.security.MessageDigest import java.text.CollationKey import java.text.Collator @@ -763,16 +764,15 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( override fun hashCode() = hashCode - override fun equals(other: Any?): Boolean { - if (other !is Raw) return false - if (musicBrainzId != null && - other.musicBrainzId != null && - musicBrainzId == other.musicBrainzId) { - return true - } - - return name.equals(other.name, true) && rawArtists == other.rawArtists - } + override fun equals(other: Any?) = + other is Raw && + when { + musicBrainzId != null && other.musicBrainzId != null -> + musicBrainzId == other.musicBrainzId + musicBrainzId == null && other.musicBrainzId == null -> + name.equals(other.name, true) && rawArtists == other.rawArtists + else -> false + } } } @@ -916,21 +916,19 @@ class Artist constructor(private val raw: Raw, songAlbums: List) : MusicP override fun hashCode() = hashCode - override fun equals(other: Any?): Boolean { - if (other !is Raw) return false - - if (musicBrainzId != null && - other.musicBrainzId != null && - musicBrainzId == other.musicBrainzId) { - return true - } - - return when { - name != null && other.name != null -> name.equals(other.name, true) - name == null && other.name == null -> true - else -> false - } - } + override fun equals(other: Any?) = + other is Raw && + when { + musicBrainzId != null && other.musicBrainzId != null -> + musicBrainzId == other.musicBrainzId + musicBrainzId == null && other.musicBrainzId == null -> + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } + else -> false + } } } @@ -1025,7 +1023,7 @@ class Genre constructor(private val raw: Raw, override val songs: List) : * @return A [UUID] converted from the [String] value, or null if the value was not valid. * @see UUID.fromString */ -fun String.toUuidOrNull(): UUID? = +private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { @@ -1036,7 +1034,8 @@ fun String.toUuidOrNull(): UUID? = * Update a [MessageDigest] with a lowercase [String]. * @param string The [String] to hash. If null, it will not be hashed. */ -private fun MessageDigest.update(string: String?) { +@VisibleForTesting +fun MessageDigest.update(string: String?) { if (string != null) { update(string.lowercase().toByteArray()) } else { @@ -1048,7 +1047,8 @@ private fun MessageDigest.update(string: String?) { * Update a [MessageDigest] with the string representation of a [Date]. * @param date The [Date] to hash. If null, nothing will be done. */ -private fun MessageDigest.update(date: Date?) { +@VisibleForTesting +fun MessageDigest.update(date: Date?) { if (date != null) { update(date.toString().toByteArray()) } else { @@ -1060,7 +1060,8 @@ private fun MessageDigest.update(date: Date?) { * Update a [MessageDigest] with the lowercase versions of all of the input [String]s. * @param strings The [String]s to hash. If a [String] is null, it will not be hashed. */ -private fun MessageDigest.update(strings: List) { +@VisibleForTesting +fun MessageDigest.update(strings: List) { strings.forEach(::update) } @@ -1068,7 +1069,8 @@ private fun MessageDigest.update(strings: List) { * Update a [MessageDigest] with the little-endian bytes of a [Int]. * @param n The [Int] to write. If null, nothing will be done. */ -private fun MessageDigest.update(n: Int?) { +@VisibleForTesting +fun MessageDigest.update(n: Int?) { if (n != null) { update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) } else { diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicTest.kt new file mode 100644 index 000000000..d1262e11f --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import java.util.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.oxycblt.auxio.music.tags.Date + +class MusicTest { + @Test + fun musicUid_auxio() { + val uid = + Music.UID.auxio(MusicMode.SONGS) { + update("Wheel") + update(listOf("Parannoul", "Asian Glow")) + update("Paraglow") + update(null as String?) + update(Date.from(2022)) + update(4 as Int?) + update(null as Int?) + } + + assertEquals("org.oxycblt.auxio:a10b-3d29c202-cd52-fbe0-4714-47cd07f07a59", uid.toString()) + } + + @Test + fun musicUid_musicBrainz() { + val uid = + Music.UID.musicBrainz( + MusicMode.ALBUMS, UUID.fromString("9b3b0695-0cdc-4560-8486-8deadee136cb")) + assertEquals("org.musicbrainz:a10a-9b3b0695-0cdc-4560-8486-8deadee136cb", uid.toString()) + } + + @Test + fun albumRaw_equals_inconsistentCase() { + val a = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = null, + name = "Paraglow", + sortName = null, + releaseType = null, + rawArtists = + listOf(Artist.Raw(name = "Parannoul"), Artist.Raw(name = "Asian Glow"))) + val b = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = null, + name = "paraglow", + sortName = null, + releaseType = null, + rawArtists = + listOf(Artist.Raw(name = "Parannoul"), Artist.Raw(name = "Asian glow"))) + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun albumRaw_equals_withMbids() { + val a = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), + name = "Weezer", + sortName = "Blue Album", + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Weezer"))) + val b = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"), + name = "Weezer", + sortName = "Green Album", + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Weezer"))) + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun albumRaw_equals_inconsistentMbids() { + val a = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), + name = "Weezer", + sortName = "Blue Album", + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Weezer"))) + val b = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = null, + name = "Weezer", + sortName = "Green Album", + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Weezer"))) + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun albumRaw_equals_withArtists() { + val a = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = null, + name = "Album", + sortName = null, + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Artist A"))) + val b = + Album.Raw( + mediaStoreId = -1, + musicBrainzId = null, + name = "Album", + sortName = null, + releaseType = null, + rawArtists = listOf(Artist.Raw(name = "Artist B"))) + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun artistRaw_equals_inconsistentCase() { + val a = Artist.Raw(musicBrainzId = null, name = "Parannoul") + val b = Artist.Raw(musicBrainzId = null, name = "parannoul") + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun artistRaw_equals_withMbids() { + val a = + Artist.Raw( + musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), + name = "Artist") + val b = + Artist.Raw( + musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"), + name = "Artist") + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun artistRaw_equals_inconsistentMbids() { + val a = + Artist.Raw( + musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), + name = "Artist") + val b = Artist.Raw(musicBrainzId = null, name = "Artist") + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun artistRaw_equals_missingNames() { + val a = Artist.Raw(name = null) + val b = Artist.Raw(name = null) + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun artistRaw_equals_inconsistentNames() { + val a = Artist.Raw(name = null) + val b = Artist.Raw(name = "Parannoul") + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } + + @Test + fun genreRaw_equals_inconsistentCase() { + val a = Genre.Raw("Future Garage") + val b = Genre.Raw("future garage") + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun genreRaw_equals_missingNames() { + val a = Genre.Raw(name = null) + val b = Genre.Raw(name = null) + assertTrue(a == b) + assertTrue(a.hashCode() == b.hashCode()) + } + + @Test + fun genreRaw_equals_inconsistentNames() { + val a = Genre.Raw(name = null) + val b = Genre.Raw(name = "Future Garage") + assertTrue(a != b) + assertTrue(a.hashCode() != b.hashCode()) + } +} From 692839e8fef580df2dabeb377e484f35b422d584 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 9 Jan 2023 13:53:37 -0700 Subject: [PATCH 27/55] playback: re-add state persistence Re-add state persistence with support for the new queue. This should finally finish the new queue system. --- .../auxio/playback/queue/QueueFragment.kt | 1 + .../playback/state/PlaybackStateDatabase.kt | 225 +++++++++++------- .../playback/state/PlaybackStateManager.kt | 121 +++++----- .../org/oxycblt/auxio/playback/state/Queue.kt | 33 +-- 4 files changed, 210 insertions(+), 170 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 50326e156..396e1eb92 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -77,6 +77,7 @@ class QueueFragment : ViewBindingFragment(), EditableListL override fun onDestroyBinding(binding: FragmentQueueBinding) { super.onDestroyBinding(binding) + touchHelper = null binding.queueRecycler.adapter = null } 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 8aea48e50..1fbbde002 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 @@ -22,6 +22,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns +import androidx.core.database.getIntOrNull import androidx.core.database.sqlite.transaction import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.library.Library @@ -40,17 +41,22 @@ class PlaybackStateDatabase private constructor(context: Context) : // of the non-queue parts of the state, such as the playback position. db.createTable(TABLE_STATE) { append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${StateColumns.INDEX} INTEGER NOT NULL,") - append("${StateColumns.POSITION} LONG NOT NULL,") - append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,") - append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,") - append("${StateColumns.SONG_UID} STRING,") - append("${StateColumns.PARENT_UID} STRING") + append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,") + append("${PlaybackStateColumns.POSITION} LONG NOT NULL,") + append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,") + append("${PlaybackStateColumns.SONG_UID} STRING,") + append("${PlaybackStateColumns.PARENT_UID} STRING") } - db.createTable(TABLE_QUEUE) { + db.createTable(TABLE_QUEUE_HEAP) { append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${QueueColumns.SONG_UID} STRING NOT NULL") + append("${QueueHeapColumns.SONG_UID} STRING NOT NULL") + } + + db.createTable(TABLE_QUEUE_MAPPINGS) { + append("${BaseColumns._ID} INTEGER PRIMARY KEY,") + append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,") + append("${QueueMappingColumns.SHUFFLED_INDEX} INT") } } @@ -61,7 +67,8 @@ class PlaybackStateDatabase private constructor(context: Context) : logD("Nuking database") db.apply { execSQL("DROP TABLE IF EXISTS $TABLE_STATE") - execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE") + execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP") + execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS") onCreate(this) } } @@ -77,63 +84,78 @@ class PlaybackStateDatabase private constructor(context: Context) : requireBackgroundThread() // Read the saved state and queue. If the state is non-null, that must imply an // existent, albeit possibly empty, queue. - val rawState = readRawState() ?: return null - val queue = readQueue(library) - // Correct the index to match up with a queue that has possibly been shortened due to - // song removals. - var actualIndex = rawState.index - while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) { - actualIndex-- - } + val rawState = readRawPlaybackState() ?: return null + val rawQueueState = readRawQueueState(library) // Restore parent item from the music library. If this fails, then the playback mode // reverts to "All Songs", which is considered okay. val parent = rawState.parentUid?.let { library.find(it) } return SavedState( - index = actualIndex, parent = parent, - queue = queue, + queueState = + Queue.SavedState( + heap = rawQueueState.heap, + orderedMapping = rawQueueState.orderedMapping, + shuffledMapping = rawQueueState.shuffledMapping, + index = rawState.index, + songUid = rawState.songUid), positionMs = rawState.positionMs, - repeatMode = rawState.repeatMode, - isShuffled = rawState.isShuffled) + repeatMode = rawState.repeatMode) } - private fun readRawState() = + private fun readRawPlaybackState() = readableDatabase.queryAll(TABLE_STATE) { cursor -> if (!cursor.moveToFirst()) { // Empty, nothing to do. return@queryAll null } - val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX) - val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION) - val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE) - val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED) - val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID) - val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID) - RawState( + val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX) + val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION) + val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE) + val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID) + val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID) + RawPlaybackState( index = cursor.getInt(indexIndex), positionMs = cursor.getLong(posIndex), repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) ?: RepeatMode.NONE, - isShuffled = cursor.getInt(shuffleIndex) == 1, songUid = Music.UID.fromString(cursor.getString(songUidIndex)) ?: return@queryAll null, parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) } - private fun readQueue(library: Library): List { - val queue = mutableListOf() - readableDatabase.queryAll(TABLE_QUEUE) { cursor -> - val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) + private fun readRawQueueState(library: Library): RawQueueState { + val heap = mutableListOf() + readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor -> + if (cursor.count == 0) { + // Empty, nothing to do. + return@queryAll + } + + val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID) while (cursor.moveToNext()) { - val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue - val song = library.find(uid) ?: continue - queue.add(song) + heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find)) + } + } + logD("Successfully read queue of ${heap.size} songs") + + val orderedMapping = mutableListOf() + val shuffledMapping = mutableListOf() + readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor -> + if (cursor.count == 0) { + // Empty, nothing to do. + return@queryAll + } + + val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX) + val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX) + while (cursor.moveToNext()) { + orderedMapping.add(cursor.getInt(orderedIndex)) + cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add) } } - logD("Successfully read queue of ${queue.size} songs") - return queue + return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull()) } /** @@ -144,40 +166,43 @@ class PlaybackStateDatabase private constructor(context: Context) : requireBackgroundThread() // Only bother saving a state if a song is actively playing from one. // This is not the case with a null state or a state with an out-of-bounds index. - if (state != null && state.index in state.queue.indices) { + if (state != null) { // Transform saved state into raw state, which can then be written to the database. - val rawState = - RawState( - index = state.index, + val rawPlaybackState = + RawPlaybackState( + index = state.queueState.index, positionMs = state.positionMs, repeatMode = state.repeatMode, - isShuffled = state.isShuffled, - songUid = state.queue[state.index].uid, + songUid = state.queueState.songUid, parentUid = state.parent?.uid) - writeRawState(rawState) - writeQueue(state.queue) + writeRawPlaybackState(rawPlaybackState) + val rawQueueState = + RawQueueState( + heap = state.queueState.heap, + orderedMapping = state.queueState.orderedMapping, + shuffledMapping = state.queueState.shuffledMapping) + writeRawQueueState(rawQueueState) logD("Wrote state") } else { - writeRawState(null) - writeQueue(null) + writeRawPlaybackState(null) + writeRawQueueState(null) logD("Cleared state") } } - private fun writeRawState(rawState: RawState?) { + private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) { writableDatabase.transaction { delete(TABLE_STATE, null, null) - if (rawState != null) { + if (rawPlaybackState != null) { val stateData = ContentValues(7).apply { put(BaseColumns._ID, 0) - put(StateColumns.SONG_UID, rawState.songUid.toString()) - put(StateColumns.POSITION, rawState.positionMs) - put(StateColumns.PARENT_UID, rawState.parentUid?.toString()) - put(StateColumns.INDEX, rawState.index) - put(StateColumns.IS_SHUFFLED, rawState.isShuffled) - put(StateColumns.REPEAT_MODE, rawState.repeatMode.intCode) + put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString()) + put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs) + put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString()) + put(PlaybackStateColumns.INDEX, rawPlaybackState.index) + put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode) } insert(TABLE_STATE, null, stateData) @@ -185,47 +210,54 @@ class PlaybackStateDatabase private constructor(context: Context) : } } - private fun writeQueue(queue: List?) { - writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song -> + private fun writeRawQueueState(rawQueueState: RawQueueState?) { + writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song -> ContentValues(2).apply { put(BaseColumns._ID, i) - put(QueueColumns.SONG_UID, song.uid.toString()) + put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString()) + } + } + + val combinedMapping = + rawQueueState?.run { + if (shuffledMapping.isNotEmpty()) { + orderedMapping.zip(shuffledMapping) + } else { + orderedMapping.map { Pair(it, null) } + } + } + + writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair -> + ContentValues(3).apply { + put(BaseColumns._ID, i) + put(QueueMappingColumns.ORDERED_INDEX, pair.first) + put(QueueMappingColumns.SHUFFLED_INDEX, pair.second) } } } /** * A condensed representation of the playback state that can be persisted. - * @param index The position of the currently playing item in the queue. Can be -1 if the - * persisted index no longer exists. - * @param queue The [Song] queue. - * @param parent The [MusicParent] item currently being played from + * @param parent The [MusicParent] item currently being played from. + * @param queueState The [Queue.SavedState] * @param positionMs The current position in the currently played song, in ms * @param repeatMode The current [RepeatMode]. - * @param isShuffled Whether the queue is shuffled or not. */ data class SavedState( - val index: Int, - val queue: List, val parent: MusicParent?, + val queueState: Queue.SavedState, val positionMs: Long, val repeatMode: RepeatMode, - val isShuffled: Boolean ) - /** - * A lower-level form of [SavedState] that contains additional information to create a more - * reliable restoration process. - */ - private data class RawState( - /** @see SavedState.index */ + /** A lower-level form of [SavedState] that contains individual field-based information. */ + private data class RawPlaybackState( + /** @see Queue.SavedState.index */ val index: Int, /** @see SavedState.positionMs */ val positionMs: Long, /** @see SavedState.repeatMode */ val repeatMode: RepeatMode, - /** @see SavedState.isShuffled */ - val isShuffled: Boolean, /** * The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be * used to restore the currently playing item in the queue if the index mapping changed. @@ -235,33 +267,50 @@ class PlaybackStateDatabase private constructor(context: Context) : val parentUid: Music.UID? ) + /** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */ + private data class RawQueueState( + /** @see Queue.SavedState.heap */ + val heap: List, + /** @see Queue.SavedState.orderedMapping */ + val orderedMapping: List, + /** @see Queue.SavedState.shuffledMapping */ + val shuffledMapping: List + ) + /** Defines the columns used in the playback state table. */ - private object StateColumns { - /** @see RawState.index */ + private object PlaybackStateColumns { + /** @see RawPlaybackState.index */ const val INDEX = "queue_index" - /** @see RawState.positionMs */ + /** @see RawPlaybackState.positionMs */ const val POSITION = "position" - /** @see RawState.isShuffled */ - const val IS_SHUFFLED = "is_shuffling" - /** @see RawState.repeatMode */ + /** @see RawPlaybackState.repeatMode */ const val REPEAT_MODE = "repeat_mode" - /** @see RawState.songUid */ + /** @see RawPlaybackState.songUid */ const val SONG_UID = "song_uid" - /** @see RawState.parentUid */ + /** @see RawPlaybackState.parentUid */ const val PARENT_UID = "parent" } - /** Defines the columns used in the queue table. */ - private object QueueColumns { + /** Defines the columns used in the queue heap table. */ + private object QueueHeapColumns { /** @see Music.UID */ const val SONG_UID = "song_uid" } + /** Defines the columns used in the queue mapping table. */ + private object QueueMappingColumns { + /** @see Queue.SavedState.orderedMapping */ + const val ORDERED_INDEX = "ordered_index" + /** @see Queue.SavedState.shuffledMapping */ + const val SHUFFLED_INDEX = "shuffled_index" + } + companion object { private const val DB_NAME = "auxio_playback_state.db" - private const val DB_VERSION = 8 + private const val DB_VERSION = 9 private const val TABLE_STATE = "playback_state" - private const val TABLE_QUEUE = "queue" + private const val TABLE_QUEUE_HEAP = "queue_heap" + private const val TABLE_QUEUE_MAPPINGS = "queue_mapping" @Volatile private var INSTANCE: PlaybackStateDatabase? = null 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 efa375415..4f0bcf641 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 @@ -155,8 +155,7 @@ class PlaybackStateManager private constructor() { /** * Start new playback. * @param song A particular [Song] to play, or null to play the first [Song] in the new queue. - * @param parent The [MusicParent] to play from, or null if to play from the entire - * [MusicStore.Library]. + * @param parent The [MusicParent] to play from, or null if to play from the entire [Library]. * @param sort [Sort] to initially sort an ordered queue with. * @param shuffled Whether to shuffle or not. */ @@ -390,7 +389,7 @@ class PlaybackStateManager private constructor() { /** * Restore the previously saved state (if any) and apply it to the playback state. * @param database The [PlaybackStateDatabase] to load from. - * @param force Whether to force a restore regardless of the current state. + * @param force Whether to do a restore regardless of any prior playback state. * @return If the state was restored, false otherwise. */ suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean { @@ -399,49 +398,37 @@ class PlaybackStateManager private constructor() { return false } - // TODO: Re-implement with new queue - return false + val library = musicStore.library ?: return false + val internalPlayer = internalPlayer ?: return false + val state = + try { + withContext(Dispatchers.IO) { database.read(library) } + } catch (e: Exception) { + logE("Unable to restore playback state.") + logE(e.stackTraceToString()) + return false + } - // val library = musicStore.library ?: return false - // val internalPlayer = internalPlayer ?: return false - // val state = - // try { - // withContext(Dispatchers.IO) { database.read(library) } - // } catch (e: Exception) { - // logE("Unable to restore playback state.") - // logE(e.stackTraceToString()) - // return false - // } - // - // // Translate the state we have just read into a usable playback state for this - // // instance. - // return synchronized(this) { - // // State could have changed while we were loading, so check if we were - // initialized - // // now before applying the state. - // if (state != null && (!isInitialized || force)) { - // index = state.index - // parent = state.parent - // _queue = state.queue.toMutableList() - // repeatMode = state.repeatMode - // isShuffled = state.isShuffled - // - // notifyNewPlayback() - // notifyRepeatModeChanged() - // notifyShuffledChanged() - // - // // Continuing playback after drastic state updates is a bad idea, so - // pause. - // internalPlayer.loadSong(song, false) - // internalPlayer.seekTo(state.positionMs) - // - // isInitialized = true - // - // true - // } else { - // false - // } - // } + // Translate the state we have just read into a usable playback state for this + // instance. + return synchronized(this) { + // State could have changed while we were loading, so check if we were initialized + // now before applying the state. + if (state != null && (!isInitialized || force)) { + parent = state.parent + queue.applySavedState(state.queueState) + repeatMode = state.repeatMode + notifyNewPlayback() + notifyRepeatModeChanged() + // Continuing playback after drastic state updates is a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + internalPlayer.seekTo(state.positionMs) + isInitialized = true + true + } else { + false + } + } } /** @@ -451,26 +438,25 @@ class PlaybackStateManager private constructor() { */ suspend fun saveState(database: PlaybackStateDatabase): Boolean { logD("Saving state to DB") - return false - // // Create the saved state from the current playback state. - // val state = - // synchronized(this) { - // PlaybackStateDatabase.SavedState( - // index = index, - // parent = parent, - // queue = _queue, - // positionMs = playerState.calculateElapsedPositionMs(), - // isShuffled = isShuffled, - // repeatMode = repeatMode) - // } - // return try { - // withContext(Dispatchers.IO) { database.write(state) } - // true - // } catch (e: Exception) { - // logE("Unable to save playback state.") - // logE(e.stackTraceToString()) - // false - // } + // Create the saved state from the current playback state. + val state = + synchronized(this) { + queue.toSavedState()?.let { + PlaybackStateDatabase.SavedState( + parent = parent, + queueState = it, + positionMs = playerState.calculateElapsedPositionMs(), + repeatMode = repeatMode) + } + } + return try { + withContext(Dispatchers.IO) { database.write(state) } + true + } catch (e: Exception) { + logE("Unable to save playback state.") + logE(e.stackTraceToString()) + false + } } /** @@ -519,8 +505,9 @@ class PlaybackStateManager private constructor() { } // Sanitize the queue. - queue.applySavedState( - queue.toSavedState().remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) + queue.toSavedState()?.let { state -> + queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) + } notifyNewPlayback() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 36655d543..7759f0b3d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -59,7 +59,13 @@ class Queue { * Resolve this queue into a more conventional list of [Song]s. * @return A list of [Song] corresponding to the current queue mapping. */ - fun resolve() = shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } + fun resolve() = + if (currentSong != null) { + shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } + } else { + // Queue doesn't exist, return saner data. + listOf() + } /** * Go to a particular index in the queue. @@ -253,12 +259,10 @@ class Queue { * @return A new [SavedState] reflecting the exact state of the queue when called. */ fun toSavedState() = - SavedState( - heap.toList(), - orderedMapping.toList(), - shuffledMapping.toList(), - index, - currentSong?.uid) + currentSong?.let { song -> + SavedState( + heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) + } /** * Update this instance from the given [SavedState]. @@ -287,8 +291,8 @@ class Queue { } // Make sure we re-align the index to point to the previously playing song. - index = savedState.currentIndex - while (currentSong?.uid != savedState.currentSongUid && index > -1) { + index = savedState.index + while (currentSong?.uid != savedState.songUid && index > -1) { index-- } check() @@ -348,15 +352,15 @@ class Queue { * other values. * @param orderedMapping The mapping of the [heap] to an ordered queue. * @param shuffledMapping The mapping of the [heap] to a shuffled queue. - * @param currentIndex The index of the currently playing [Song] at the time of serialization. - * @param currentSongUid The [Music.UID] of the [Song] that was originally at [currentIndex]. + * @param index The index of the currently playing [Song] at the time of serialization. + * @param songUid The [Music.UID] of the [Song] that was originally at [index]. */ class SavedState( val heap: List, val orderedMapping: List, val shuffledMapping: List, - val currentIndex: Int, - val currentSongUid: Music.UID?, + val index: Int, + val songUid: Music.UID, ) { /** * Remaps the [heap] of this instance based on the given mapping function and copies it into @@ -367,8 +371,7 @@ class Queue { * @throws IllegalStateException If the invariant specified by [transform] is violated. */ inline fun remap(transform: (Song?) -> Song?) = - SavedState( - heap.map(transform), orderedMapping, shuffledMapping, currentIndex, currentSongUid) + SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) } /** From 5988908b569b42a3301ac0f2bb8d5afaf360c01c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 10 Jan 2023 08:12:47 -0700 Subject: [PATCH 28/55] playback: add ability to play/shuffle selection Add the ability to play or shuffle a selection. This finally allows "arbitrary" playback to be created from any combination of songs/albums/artist/genres, rather than just from pre-defined options. Resolves #313. --- .../auxio/list/selection/SelectionFragment.kt | 8 +++ .../org/oxycblt/auxio/music/MusicStore.kt | 2 +- .../oxycblt/auxio/music/storage/Filesystem.kt | 1 - .../auxio/music/storage/StorageUtil.kt | 2 +- .../auxio/playback/PlaybackViewModel.kt | 49 ++++++++++--------- .../playback/state/PlaybackStateManager.kt | 7 +-- .../auxio/playback/system/PlaybackService.kt | 5 +- .../main/res/menu/menu_selection_actions.xml | 18 +++---- 8 files changed, 50 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index a5d762c42..32edb8f7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -71,6 +71,14 @@ abstract class SelectionFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_selection_play -> { + playbackModel.play(selectionModel.consume()) + true + } + R.id.action_selection_shuffle -> { + playbackModel.shuffle(selectionModel.consume()) + true + } else -> false } } 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 cb04ef3e4..2e9bbab2d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -58,7 +58,7 @@ class MusicStore private constructor() { } /** - * Remove a [Listener] from this instance, preventing it from recieving any further updates. + * Remove a [Listener] from this instance, preventing it from receiving any further updates. * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in * the first place. * @see Listener diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt index 00a22deaf..5536e46df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt @@ -129,7 +129,6 @@ class Directory private constructor(val volume: StorageVolume, val relativePath: * @author Alexander Capehart (OxygenCobalt) */ data class MusicDirectories(val dirs: List, val shouldInclude: Boolean) -// TODO: Unify include + exclude /** * A mime type of a file. Only intended for display. diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt index 60bc797e9..6de6c4b3f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt @@ -196,7 +196,7 @@ val StorageVolume.isInternalCompat: Boolean get() = isPrimaryCompat && isEmulatedCompat /** - * The unique identifier for this [StorageVolume], obtained in a version compatible manner Can be + * The unique identifier for this [StorageVolume], obtained in a version compatible manner. Can be * null. * @see StorageVolume.getUuid */ 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 e44651763..c46f6ad94 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -38,6 +38,7 @@ class PlaybackViewModel(application: Application) : private val musicSettings = MusicSettings.from(application) private val playbackSettings = PlaybackSettings.from(application) private val playbackManager = PlaybackStateManager.getInstance() + private val musicStore = MusicStore.getInstance() private var lastPositionJob: Job? = null private val _song = MutableStateFlow(null) @@ -161,27 +162,13 @@ class PlaybackViewModel(application: Application) : */ fun playFrom(song: Song, playbackMode: MusicMode) { when (playbackMode) { - MusicMode.SONGS -> playFromAll(song) - MusicMode.ALBUMS -> playFromAlbum(song) + MusicMode.SONGS -> playImpl(song, null) + MusicMode.ALBUMS -> playImpl(song, song.album) MusicMode.ARTISTS -> playFromArtist(song) MusicMode.GENRES -> playFromGenre(song) } } - /** - * Play the given [Song] from all songs in the music library. - * @param song The [Song] to play. - */ - fun playFromAll(song: Song) { - playImpl(song, null) - } - - /** - * Play a [Song] from it's [Album]. - * @param song The [Song] to play. - */ - fun playFromAlbum(song: Song) = playImpl(song, song.album) - /** * Play a [Song] from one of it's [Artist]s. * @param song The [Song] to play. @@ -250,6 +237,13 @@ class PlaybackViewModel(application: Application) : */ fun play(genre: Genre) = playImpl(null, genre, false) + /** + * Play a [Music] selection. + * @param selection The selection to play. + */ + fun play(selection: List) = + playbackManager.play(null, selectionToSongs(selection), false) + /** * Shuffle an [Album]. * @param album The [Album] to shuffle. @@ -269,13 +263,11 @@ class PlaybackViewModel(application: Application) : fun shuffle(genre: Genre) = playImpl(null, genre, true) /** - * Start the given [InternalPlayer.Action] to be completed eventually. This can be used to - * enqueue a playback action at startup to then occur when the music library is fully loaded. - * @param action The [InternalPlayer.Action] to perform eventually. + * Shuffle a [Music] selection. + * @param selection The selection to shuffle. */ - fun startAction(action: InternalPlayer.Action) { - playbackManager.startAction(action) - } + fun shuffle(selection: List) = + playbackManager.play(null, selectionToSongs(selection), true) private fun playImpl( song: Song?, @@ -285,6 +277,7 @@ class PlaybackViewModel(application: Application) : check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } + val library = musicStore.library ?: return val sort = when (parent) { is Genre -> musicSettings.genreSongSort @@ -292,7 +285,17 @@ class PlaybackViewModel(application: Application) : is Album -> musicSettings.albumSongSort null -> musicSettings.songSort } - playbackManager.play(song, parent, sort, shuffled) + val queue = sort.songs(parent?.songs ?: library.songs) + playbackManager.play(song, queue, shuffled) + } + + /** + * Start the given [InternalPlayer.Action] to be completed eventually. This can be used to + * enqueue a playback action at startup to then occur when the music library is fully loaded. + * @param action The [InternalPlayer.Action] to perform eventually. + */ + fun startAction(action: InternalPlayer.Action) { + playbackManager.startAction(action) } // --- PLAYER FUNCTIONS --- 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 4f0bcf641..2cc2cf99d 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 @@ -155,20 +155,21 @@ class PlaybackStateManager private constructor() { /** * Start new playback. * @param song A particular [Song] to play, or null to play the first [Song] in the new queue. + * @param queue The queue of [Song]s to play from. * @param parent The [MusicParent] to play from, or null if to play from the entire [Library]. * @param sort [Sort] to initially sort an ordered queue with. * @param shuffled Whether to shuffle or not. */ @Synchronized - fun play(song: Song?, parent: MusicParent?, sort: Sort, shuffled: Boolean) { + fun play(song: Song?, queue: List, shuffled: Boolean) { val internalPlayer = internalPlayer ?: return val library = musicStore.library ?: return // Set up parent and queue this.parent = parent - queue.start(song, sort.songs(parent?.songs ?: library.songs), shuffled) + this.queue.start(song, queue, shuffled) // Notify components of changes notifyNewPlayback() - internalPlayer.loadSong(queue.currentSong, true) + internalPlayer.loadSong(this.queue.currentSong, true) // Played something, so we are initialized now isInitialized = true } 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 df7da5c53..aa2d254d0 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 @@ -355,15 +355,14 @@ class PlaybackService : } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, null, musicSettings.songSort, true) + playbackManager.play(null, musicSettings.songSort.songs(library.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { library.findSongForUri(application, action.uri)?.let { song -> playbackManager.play( song, - null, - musicSettings.songSort, + musicSettings.songSort.songs(library.songs), playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } } diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/menu_selection_actions.xml index 841bbcd8d..ad023050c 100644 --- a/app/src/main/res/menu/menu_selection_actions.xml +++ b/app/src/main/res/menu/menu_selection_actions.xml @@ -8,16 +8,14 @@ app:showAsAction="ifRoom"/> - - - - - - - - - + + \ No newline at end of file From 176f0cc4656d4d247bab9db69c76eed350ec1e8e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 14 Jan 2023 19:53:24 -0700 Subject: [PATCH 29/55] list: add update instructions framework Add the basic framework that should allow for different types of list updates in different situations. --- CHANGELOG.md | 3 + .../java/org/oxycblt/auxio/MainFragment.kt | 2 - .../oxycblt/auxio/list/UpdateInstructions.kt | 19 ++++ .../auxio/playback/PlaybackViewModel.kt | 6 +- .../auxio/playback/queue/QueueFragment.kt | 14 +-- .../auxio/playback/queue/QueueViewModel.kt | 91 +++++++++---------- .../playback/state/PlaybackStateManager.kt | 12 +-- .../org/oxycblt/auxio/playback/state/Queue.kt | 3 +- .../auxio/playback/system/PlaybackService.kt | 3 +- .../oxycblt/auxio/settings/AboutFragment.kt | 16 ++-- .../org/oxycblt/auxio/settings/Settings.kt | 7 +- .../auxio/ui/BaseBottomSheetBehavior.kt | 2 +- .../auxio/ui/ViewBindingDialogFragment.kt | 5 +- .../oxycblt/auxio/ui/ViewBindingFragment.kt | 5 +- 14 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bd13e9b..24adc7035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +#### What's New +- Added ability to play/shuffle selections + #### What's Improved - Added ability to edit previously played or currently playing items in the queue - Added support for date values formatted as "YYYYMMDD" diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 8b0c32112..67c80091c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -343,10 +343,8 @@ class MainFragment : if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? - // Queue sheet behavior is either collapsed or expanded, no hiding needed queueSheetBehavior?.isDraggable = true - playbackSheetBehavior.apply { // Make sure the view is draggable, at least until the draw checks kick in. isDraggable = true diff --git a/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt b/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt new file mode 100644 index 000000000..1c741aa56 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt @@ -0,0 +1,19 @@ +package org.oxycblt.auxio.list + +/** + * Represents the specific way to update a list of items. + * @author Alexander Capehart (OxygenCobalt) + */ +enum class UpdateInstructions { + /** + * (A)synchronously diff the list. This should be used for small diffs with little item + * movement. + */ + DIFF, + + /** + * Synchronously remove the current list and replace it with a new one. This should be used + * for large diffs with that would cause erratic scroll behavior or in-efficiency. + */ + REPLACE +} \ No newline at end of file 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 c46f6ad94..143253b99 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -242,7 +242,7 @@ class PlaybackViewModel(application: Application) : * @param selection The selection to play. */ fun play(selection: List) = - playbackManager.play(null, selectionToSongs(selection), false) + playbackManager.play(null, null, selectionToSongs(selection), false) /** * Shuffle an [Album]. @@ -267,7 +267,7 @@ class PlaybackViewModel(application: Application) : * @param selection The selection to shuffle. */ fun shuffle(selection: List) = - playbackManager.play(null, selectionToSongs(selection), true) + playbackManager.play(null, null, selectionToSongs(selection), true) private fun playImpl( song: Song?, @@ -286,7 +286,7 @@ class PlaybackViewModel(application: Application) : null -> musicSettings.songSort } val queue = sort.songs(parent?.songs ?: library.songs) - playbackManager.play(song, queue, shuffled) + playbackManager.play(song, parent, queue, shuffled) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 396e1eb92..7929dd4ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.list.UpdateInstructions import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -100,18 +101,19 @@ class QueueFragment : ViewBindingFragment(), EditableListL val binding = requireBinding() // Replace or diff the queue depending on the type of change it is. - // TODO: Extend this to the whole app. - if (queueModel.replaceQueue == true) { + val instructions = queueModel.instructions + if (instructions?.update == UpdateInstructions.REPLACE) { logD("Replacing queue") queueAdapter.replaceList(queue) } else { logD("Diffing queue") queueAdapter.submitList(queue) } - queueModel.finishReplace() + // Update position in list (and thus past/future items) + queueAdapter.setPosition(index, isPlaying) // If requested, scroll to a new item (occurs when the index moves) - val scrollTo = queueModel.scrollTo + val scrollTo = instructions?.scrollTo if (scrollTo != null) { val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager val start = lmm.findFirstCompletelyVisibleItemPosition() @@ -132,9 +134,7 @@ class QueueFragment : ViewBindingFragment(), EditableListL min(queue.lastIndex, scrollTo + (end - start))) } } - queueModel.finishScrollTo() - // Update position in list (and thus past/future items) - queueAdapter.setPosition(index, isPlaying) + queueModel.finishInstructions() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 5c5e5d8d3..1c4709e15 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.list.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager @@ -42,15 +43,47 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { val index: StateFlow get() = _index - /** Whether to replace or diff the queue list when updating it. Is null if not specified. */ - var replaceQueue: Boolean? = null - /** Flag to scroll to a particular queue item. Is null if no command has been specified. */ - var scrollTo: Int? = null + /** Specifies how to update the list when the queue changes. */ + var instructions: Instructions? = null init { playbackManager.addListener(this) } + override fun onIndexMoved(queue: Queue) { + instructions = Instructions(null, queue.index) + _index.value = queue.index + } + + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. + instructions = Instructions(UpdateInstructions.DIFF, null) + _queue.value = queue.resolve() + if (change != Queue.ChangeResult.MAPPING) { + // Index changed, make sure it remains updated without actually scrolling to it. + _index.value = queue.index + } + } + + override fun onQueueReordered(queue: Queue) { + // Queue changed completely -> Replace queue, update index + instructions = Instructions(UpdateInstructions.REPLACE, null) + _queue.value = queue.resolve() + _index.value = queue.index + } + + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + // Entirely new queue -> Replace queue, update index + instructions = Instructions(UpdateInstructions.REPLACE, null) + _queue.value = queue.resolve() + _index.value = queue.index + } + + override fun onCleared() { + super.onCleared() + playbackManager.removeListener(this) + } + /** * Start playing the the queue item at the given index. * @param adapterIndex The index of the queue item to play. Does nothing if the index is out of @@ -86,52 +119,10 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { return true } - /** Finish a replace flag specified by [replaceQueue]. */ - fun finishReplace() { - replaceQueue = null + /** Signal that the specified [Instructions] in [instructions] were performed. */ + fun finishInstructions() { + instructions = null } - /** Finish a scroll operation started by [scrollTo]. */ - fun finishScrollTo() { - scrollTo = null - } - - override fun onIndexMoved(queue: Queue) { - // Index moved -> Scroll to new index - replaceQueue = null - scrollTo = queue.index - _index.value = queue.index - } - - override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { - // Queue changed trivially due to item mo -> Diff queue, stay at current index. - replaceQueue = false - scrollTo = null - _queue.value = queue.resolve() - if (change != Queue.ChangeResult.MAPPING) { - // Index changed, make sure it remains updated without actually scrolling to it. - _index.value = queue.index - } - } - - override fun onQueueReordered(queue: Queue) { - // Queue changed completely -> Replace queue, update index - replaceQueue = true - scrollTo = queue.index - _queue.value = queue.resolve() - _index.value = queue.index - } - - override fun onNewPlayback(queue: Queue, parent: MusicParent?) { - // Entirely new queue -> Replace queue, update index - replaceQueue = true - scrollTo = queue.index - _queue.value = queue.resolve() - _index.value = queue.index - } - - override fun onCleared() { - super.onCleared() - playbackManager.removeListener(this) - } + class Instructions(val update: UpdateInstructions?, val scrollTo: Int?) } 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 2cc2cf99d..8b729379c 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 @@ -23,7 +23,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -60,7 +59,7 @@ class PlaybackStateManager private constructor() { val queue = Queue() /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ @Volatile - var parent: MusicParent? = null + var parent: MusicParent? = null // TODO: Parent is interpreted wrong when nothing is playing. private set /** The current [InternalPlayer] state. */ @@ -98,7 +97,7 @@ class PlaybackStateManager private constructor() { } /** - * Remove a [Listener] from this instance, preventing it from recieving any further updates. + * Remove a [Listener] from this instance, preventing it from receiving any further updates. * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in * the first place. * @see Listener @@ -156,14 +155,13 @@ class PlaybackStateManager private constructor() { * Start new playback. * @param song A particular [Song] to play, or null to play the first [Song] in the new queue. * @param queue The queue of [Song]s to play from. - * @param parent The [MusicParent] to play from, or null if to play from the entire [Library]. - * @param sort [Sort] to initially sort an ordered queue with. + * @param parent The [MusicParent] to play from, or null if to play from an non-specific + * collection of "All [Song]s". * @param shuffled Whether to shuffle or not. */ @Synchronized - fun play(song: Song?, queue: List, shuffled: Boolean) { + fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { val internalPlayer = internalPlayer ?: return - val library = musicStore.library ?: return // Set up parent and queue this.parent = parent this.queue.start(song, queue, shuffled) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt index 7759f0b3d..6638901fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -208,8 +208,9 @@ class Queue { // We have moved an song from in front of the playing song to behind, shift forward. in dst until src -> index += 1 else -> { + // Nothing to do. check() - ChangeResult.MAPPING + return ChangeResult.MAPPING } } check() 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 aa2d254d0..08b820a5e 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 @@ -355,13 +355,14 @@ class PlaybackService : } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, musicSettings.songSort.songs(library.songs), true) + playbackManager.play(null, null, musicSettings.songSort.songs(library.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { library.findSongForUri(application, action.uri)?.let { song -> playbackManager.play( song, + null, musicSettings.songSort.songs(library.songs), playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index bb4d9109b..bd6dfe9f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -123,15 +123,13 @@ class AboutFragment : ViewBindingFragment() { if (pkgName == "android") { // No default browser [Must open app chooser, may not be supported] openAppChooser(browserIntent) - } else { - try { - browserIntent.setPackage(pkgName) - startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // Not a browser but an app chooser - browserIntent.setPackage(null) - openAppChooser(browserIntent) - } + } else try { + browserIntent.setPackage(pkgName) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser + browserIntent.setPackage(null) + openAppChooser(browserIntent) } } else { // No app installed to open the link 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 c1804d859..92a81fa26 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -88,6 +88,11 @@ interface Settings { onSettingChanged(key, unlikelyToBeNull(listener)) } - open fun onSettingChanged(key: String, listener: L) {} + /** + * Called when a setting entry with the given [key] has changed. + * @param key The key of the changed setting. + * @param listener The implementation's listener that updates should be applied to. + */ + protected open fun onSettingChanged(key: String, listener: L) {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt index fdc0c9671..1a7a9ec89 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt @@ -56,7 +56,7 @@ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: /** * Called when window insets are being applied to the [View] this [BaseBottomSheetBehavior] is * linked to. - * @param child The child view recieving the [WindowInsets]. + * @param child The child view receiving the [WindowInsets]. * @param insets The [WindowInsets] to apply. * @return The (possibly modified) [WindowInsets]. * @see View.onApplyWindowInsets diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt index fd362131d..ae53a8a7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt @@ -74,12 +74,11 @@ abstract class ViewBindingDialogFragment : DialogFragment() { * @return The currently-inflated [ViewBinding]. * @throws IllegalStateException if the [ViewBinding] is not inflated. */ - protected fun requireBinding(): VB { - return requireNotNull(_binding) { + protected fun requireBinding() = + requireNotNull(_binding) { "ViewBinding was available. Fragment should be a valid state " + "right now, but instead it was ${lifecycle.currentState}" } - } final override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt index b5ece20e2..aaaf3119e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt @@ -65,12 +65,11 @@ abstract class ViewBindingFragment : Fragment() { * @return The currently-inflated [ViewBinding]. * @throws IllegalStateException if the [ViewBinding] is not inflated. */ - protected fun requireBinding(): VB { - return requireNotNull(_binding) { + protected fun requireBinding() = + requireNotNull(_binding) { "ViewBinding was available. Fragment should be a valid state " + "right now, but instead it was ${lifecycle.currentState}" } - } final override fun onCreateView( inflater: LayoutInflater, From 5f9169fb7830a54df104a57dc26628849a239e86 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 14 Jan 2023 20:49:13 -0700 Subject: [PATCH 30/55] music: add non-standard artist fields Add non-standard ARTISTS/ALBUMARTIST fields to reasonable places in the ID3v2 and Vorbis parsers. Turns out these are stupidly common when multi-artist information is used in order to maximize player compatibility. More or less, TPE1 and ARTIST will be filled in with delimited values, while ARTISTS will be filled in with native multi-value information. This is stupid and insane, but I want to prioritize a good out of box experience without much user fiddling, so I may as well implement these. Currently, I'm only adding the non-standard fields that I know exist. This doesn't include hypothetical but unencountered fields like TXXX:ALBUMARTISTS. --- .../auxio/music/extractor/MetadataExtractor.kt | 13 +++++++------ .../oxycblt/auxio/playback/queue/QueueFragment.kt | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) 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 7910168f1..c2faf0eab 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 @@ -213,7 +213,7 @@ class Task(context: Context, private val raw: Song.Raw) { // Artist textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } - textFrames["TPE1"]?.let { raw.artistNames = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } textFrames["TSOP"]?.let { raw.artistSortNames = it } // Album artist @@ -304,15 +304,16 @@ class Task(context: Context, private val raw: Song.Raw) { // Artist comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } - comments["artist"]?.let { raw.artistNames = it } - comments["artistsort"]?.let { raw.artistSortNames = it } + (comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it } + (comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it } // Album artist comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } - comments["albumartist"]?.let { raw.albumArtistNames = it } - comments["albumartistsort"]?.let { raw.albumArtistSortNames = it } + (comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } + (comments["albumartists_sort"] ?: comments["albumartistsort"]) + ?.let { raw.albumArtistSortNames = it } // Genre - comments["GENRE"]?.let { raw.genreNames = it } + comments["genre"]?.let { raw.genreNames = it } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 7929dd4ed..879a2879f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -128,8 +128,8 @@ class QueueFragment : ViewBindingFragment(), EditableListL binding.queueRecycler.scrollToPosition(scrollTo) } else if (scrollTo > end) { // We need to scroll downwards, we need to offset by a screen of songs. - // This does have some error due to what the layout manager returns being - // somewhat mutable. This is considered okay. + // This does have some error due to how many completely visible items on-screen + // can vary. This is considered okay. binding.queueRecycler.scrollToPosition( min(queue.lastIndex, scrollTo + (end - start))) } From b524beb0ac013da2307e26be074bf8e9d1eb3092 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 15 Jan 2023 04:58:20 +0100 Subject: [PATCH 31/55] Translations update from Hosted Weblate (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Spanish) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 22.9% (56 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/ * Translated using Weblate (Turkish) Currently translated at 68.4% (167 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (Turkish) Currently translated at 100.0% (27 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/tr/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 96.2% (26 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Turkish) Currently translated at 70.0% (171 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (Portuguese (Portugal)) Currently translated at 99.5% (243 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/ * Translated using Weblate (Turkish) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (French) Currently translated at 52.8% (129 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (French) Currently translated at 96.2% (26 of 27 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fr/ * Translated using Weblate (Italian) Currently translated at 100.0% (244 of 244 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ Co-authored-by: gallegonovato Co-authored-by: BMN Co-authored-by: p4ssen <244045932@qq.com> Co-authored-by: Bai Co-authored-by: Ettore Atalan Co-authored-by: metezd Co-authored-by: ssantos Co-authored-by: Ömer Faruk Çakmak Co-authored-by: Max Vyr Co-authored-by: Translator-3000 --- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 26 ++++ app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 46 +++++- app/src/main/res/values-tr/strings.xml | 137 +++++++++++++----- app/src/main/res/values-uk/strings.xml | 10 +- app/src/main/res/values-zh-rTW/strings.xml | 23 ++- .../android/fr-FR/short_description.txt | 1 + .../metadata/android/tr/full_description.txt | 4 +- .../android/zh-Hant/short_description.txt | 1 + 11 files changed, 204 insertions(+), 49 deletions(-) create mode 100644 fastlane/metadata/android/fr-FR/short_description.txt create mode 100644 fastlane/metadata/android/zh-Hant/short_description.txt diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 852469d09..e9eb164c7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -244,7 +244,7 @@ Aus Schnell Hohe Qualität - Mitarbeiter ausblenden + Mitarbeitende ausblenden Nur Künstler anzeigen, die direkt auf einem Album erwähnt werden (funktioniert am besten mit gut getaggten Bibliotheken) %d Künstler diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 35484a777..92c10910f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -239,7 +239,7 @@ Apagado Modo de repetición Más (+) - Y comercial (&) + Y (&) Detener la reproducción Ignorar archivos de audio que no sean música, como podcasts Advertencia: El uso de esta configuración puede dar lugar a que algunas etiquetas se interpreten incorrectamente como si tuvieran varios valores. Puede resolverlo anteponiendo a los caracteres separadores no deseados una barra invertida (\\). diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e501ec098..b59ecb6c7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -128,4 +128,30 @@ Album de remixes Genre Égaliseur + Lecture aléatoire de tous les titres + Auxio icône + Couverture de l\'album + Genre inconnu + Dynamique + Cyan + Lecture aléatoire sélectionnée + Réinitialiser + Aucun dossier + Supprimer le dossier + Artiste inconnu + Format inconnu + Compilation en direct + Compilations de remix + Mixes + Mix + Ce dossier n\'est pas pris en charge + Réinitialiser + Ogg audio + Violet Claire + MPEG-1 audio + Échec du chargement de la musique + Wiki + MPEG-4 audio + Pas de date + Couverture de l\'album pour %s \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c9a88b9bf..e19fd6327 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -261,4 +261,5 @@ Riproduci dal genere Wiki %1$s, %2$s + Ripristina \ No newline at end of file diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e613e1ec3..c228f5a9d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -26,7 +26,7 @@ Versão Código fonte Licenças - Desenvolvido por OxygenCobalt + Desenvolvido por Alexander Capehart Definições Aparência @@ -216,4 +216,48 @@ %d kbps %d Hz A carregar a sua biblioteca de músicas… (%1$d/%2$d) + Retroceder antes de voltar + Parar reprodução + Reproduzir selecionada(s) + Aleatorizar selecionadas + Caminho principal + Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas) + %d Selecionadas + Mixes + Mix + Aleatório + Ocultar artistas colaboradores + Limpa os metadados em cache e recarrega totalmente a biblioteca de música (lento, porém mais completo) + Álbum de Remix + Single ao vivo + Single remix + Monitorando alterações na sua biblioteca de músicas… + Recarrega a biblioteca de músicas sempre que ela mudar (requer notificação fixa) + Redefinir + Wiki + Visualize e controle a reprodução de música + Use um tema preto + Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos) + Preferir faixa + Pré-amplificação da normalização de volume + Ao tocar a partir dos detalhes do item + Tocar a partir do gênero + Retrocede a música antes de voltar para a anterior + Recarregar música + %1$s, %2$s + Não foi possível limpar a lista + Não foi possível gravar a lista + Re-escanear músicas + Nenhuma lista pode ser restaurada + Ícone do Auxio + Aleatorizar tudo + Ao tocar da biblioteca + Singles + Single + Recarrega a biblioteca de músicas usando metadados salvos em cache quando possível + + %d artista + %d artistas + %d artistas + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5ef8cd202..6dee9b3a4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2,20 +2,20 @@ Yeniden dene - İzin + İzin ver Türler Sanatçılar Albümler Şarkılar - Şarkılar + Bütün Şarkılar Ara - Filtrele - Tümü + Süz + Hepsi Sıralama Artan - Başlat + Çal Karıştır - Şuan çalınan + Şu an çalan Kuyruk Sonraki şarkı Kuyruğa ekle @@ -24,11 +24,11 @@ Albüme git Hakkında Sürüm - GitHub\'da görüntüle + Kaynak kodu Lisanslar Ayarlar - Ayarlar + Görünüm Tema Otomatik Açık @@ -42,15 +42,15 @@ Kitaplığınız taranıyor… Parça %d - Başlat/Durdur + Çal veya Durdur - %d Şarkı - %d Şarkılar + %d şarkı + %d şarkı - %d Albüm - %d Albümler + %d albüm + %d albüm Dosya adı Özellikleri görüntüle @@ -58,9 +58,9 @@ Ana yol Biçim Karıştır - Tümünü karıştır + Hepsini karıştır Tamam - Iptal + İptal Ekle Kaydet Toplam süre: %s @@ -72,18 +72,18 @@ Örnek hızı Müzik çalmayı görüntüle ve kontrol et Android için basit, rasyonel bir müzik çalar. - Müzik Yükleniyor + Müzik yükleniyor Müzik kitaplığınız yükleniyor… - İsim + Ad Sanatçı Albüm Yıl Süre Durum kaydedildi - OxygenCobalt tarafından geliştirildi + Alexander Capehart tarafından geliştirildi Siyah tema Kitaplık istatistikleri - Saf siyah koyu tema kullan + Kapkara koyu tema kullan Görüntü Kitaplık sekmeleri Kitaplık sekmelerinin görünürlüğünü ve sırasını değiştirin @@ -97,8 +97,8 @@ Albümden çal Müzik klasörleri Müzik yalnızca eklediğiniz klasörlerden yüklenecektir. - %s için Albüm Kapağı - %s için Sanatçı Resmi + %s Albümünün kapağı + %s Sanatçısının resmi Parça numarası yok Bilinmeyen biçim Bit Hızı yok @@ -122,14 +122,14 @@ Yüklenen türler: %d %d Hz Müzik çalmıyor - %s için Tür Resmi + %s Türünün resmi Bilinmeyen tür Bilinmeyen sanatçı MPEG-1 Ses MPEG-4 Ses Ogg Ses Mavi - İndigo + Çivit Disk %d Kahverengi Gri @@ -140,10 +140,10 @@ Müzik kitaplığınız yükleniyor… (%1$d/%2$d) Yüklenen şarkılar: %d Yüklenen albümler: %d - Alternatif bildirim eylemi kullan + Özel bildirim eylemi Çalan bir albüm varsa tercih et - Kulaklıkta otomatik oynatma - Bir kulaklık takıldığında müzik çalmaya başlar (tüm cihazlarda çalışmayabilir) + Kulaklıkta otomatik çalma + Bir kulaklık takıldığında müzik çalmaya başlar (bütün cihazlarda çalışmayabilir) Parçayı tercih et Albümü tercih et Geri atlamadan önce geriye sar @@ -154,8 +154,8 @@ Mevcut çalma durumunu şimdi kaydet Karıştırmayı hatırla Yeni bir şarkı çalarken karışık çalmayı açık tut - Müziği yeniden yükle - Uygulamayı yeniden başlatacaktır + Müziği yenile + Mümkün olduğunda önbelleğe alınmış etiketleri kullanarak müzik kitaplığını yeniden yükleyin Klasör yok Bu klasör desteklenmiyor Sonraki şarkıya geç @@ -163,10 +163,10 @@ Tekrarlama modunu değiştir Müzik yüklemesi başarısız oldu Auxio\'nun müzik kitaplığınızı görüntülemek için izne ihtiyacı var - Bu bağlantıyı açabilecek bir uygulama yok + Bu görevi yerine getirebilecek bir uygulama bulunamadı Karıştırmayı açın veya kapatın - Dizini kaldır - Tüm şarkıları karıştır + Klasörü kaldır + Bütün şarkıları karıştır Auxio simgesi Bu sekmeyi taşı Albüm kapağı @@ -182,20 +182,81 @@ Deniz mavisi Ek arayüz öğelerinde yuvarlatılmış köşeleri etkinleştirir (Albüm kapaklarının yuvarlatılmış olmasını gerektirir) Durum geri yüklendi - Önceden kaydedilmiş oynatma durumunu geri getirir (varsa) + Önceden kayıtlı çalma durumunu geri getir (varsa) Yuvarlak mod - Oynatma durumunu eski haline getir - Hiçbir durum geri getirelemedi + Çalma durumunu eski haline getir + Durum geri getirelemedi Tekrarda duraklat Müzik yükleniyor Müzik kitaplığı denetleniyor Müzik kitaplığı değişiklikler için denetleniyor… Otomatik yeniden yükleme - Müzik kitaplığınız her değiştiğinde yeniden yükler (Deneysel) + Müzik kitaplığı her değiştiğinde yeniden yükleyin (kalıcı bildirim gerektirir) Eklendiği tarih Remix albüm Canlı albüm - Kuyruktan bu şarkıyı kaldır - Parçalar - Parça + Bu şarkıyı kuyruktan kaldır + Tekliler + Tekli + Karışık kaset + Seçileni çal + Karışık seçildi + Canlı derleme + Remiks derlemeler + Ekolayzır + Canlı EP + Remiks EP + Canlı tekli + Remiks tekli + Derlemeler + Derleme + Canlı + Sıfırla + Tür + EP\'ler + EP + Karışık kasetler + Durum temizlendi + Remiksler + Film Müzikleri + Film Müziği + Yüksek kaliteli + Katılımcaları gizleyin + Yalnızca bir albümde doğrudan adı geçen sanatçıları gösterin (iyi etiketlenmiş kütüphanelerde en iyi sonucu verir) + Albüm kapakları + Kapalı + Hızlı + Çalma durumunu temizle + Önceki kayıtlı çalma durumunu temizle (varsa) + %d Seçili + + %d sanatçı + %d sanatçı + + Karmalar + Karma + Etiket önbelleğini temizleyin ve müzik kitaplığını tamamen yeniden yükleyin (daha yavaş, ancak daha eksiksiz) + Çok değerli ayırıcılar + Birden fazla etiket değerini ifade eden karakterleri yapılandırın + Virgül (,) + Noktalı virgül (;) + Artı (+) + Ve (&) + Durum kaydedilemedi + Çalmayı durdur + Viki + Müzikleri yeniden tara + Özel çalma çubuğu eylemi + Sonrakine geç + Eğik çizgi (/) + Kuyruğu aç + Tekrar kipi + Türden çal + Podcast\'ler gibi müzik olmayan ses dosyalarını yok say + Uyarı: Bu ayarın kullanılması bazı etiketlerin yanlışlıkla birden fazla değere sahip olarak yorumlanmasına neden olabilir. Bunu, istenmeyen ayırıcı karakterlerin önüne ters eğik çizgi (\\) koyarak çözebilirsiniz. + Müzik olmayanları hariç tut + Durum temizlenemedi + ReplayGain stratejisi + Bu şarkıyı kuyrukta taşı + %1$s, %2$s \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1cf923333..55b2d78cb 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -203,7 +203,7 @@ Перейти до наступної пісні Ввімкніть або вимкніть перемішування Зупинити відтворення - Free Lossless Audio Codec (FLAC) + Вільний аудіокодек без втрат (FLAC) Темно-фіолетовий %d Вибрано Завантаження музичної бібліотеки… (%1$d/%2$d) @@ -242,7 +242,7 @@ Фото виконавця %s Невідомий формат Зображення жанру %s - MPEG-1 audio + Звук MPEG-1 Не знайдено жодної програми, яка б могла впоратися з цим завданням Дата відсутня Номер пісні невідомий @@ -251,7 +251,7 @@ Не вдалось зберегти статус відтворення Перейти до попередньої пісні Червоний - MPEG-4 audio + Звук MPEG-4 Перемішати всі пісні Іконка Auxio Рожевий @@ -261,8 +261,8 @@ Диск %d Не вдалося завантажити музику Видалити папку - Advanced Audio Coding (AAC) - Matroska audio + Розширене кодування звуку (AAC) + Звук Matroska %1$s, %2$s Auxio потрібен дозвіл на читання вашої музичної бібліотеки \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8e645d0ff..4ffbb7533 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -33,7 +33,7 @@ 強調色 淺色 深色 - 自定義 + 色彩樣式 音訊 行為 記住隨機播放 @@ -51,4 +51,25 @@ %d 專輯 + 載入音樂 + 檢測音樂庫 + 格式 + 黑色主題 + 時長 + 大小 + 位元率 + 取樣頻率 + 使用純黑暗色主題 + 顯示 + 新增 + 重設 + 正在載入你的音樂庫… + 流派 + 專輯封面 + 儲存 + 載入音樂中 + 一款為 android 打造的簡潔、理性的音樂播放器。 + 專輯 + 單曲 + 單曲 \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 000000000..a495d4d0f --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Un lecteur de musique simple et rationnel diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt index 559068611..e5b9134cf 100644 --- a/fastlane/metadata/android/tr/full_description.txt +++ b/fastlane/metadata/android/tr/full_description.txt @@ -1,4 +1,4 @@ -Auxio, diğer müzik oynatıcılarda bulunan birçok gereksiz özellik olmadan hızlı, güvenilir bir kullanıcı arayüzüne ve deneyimine sahip yerel bir müzik çalardır. Exoplayer üzerine inşa edilen Auxio, yerel MediaPlayer API'sini kullanan diğer uygulamalara kıyasla çok daha iyi bir dinleme deneyimine sahiptir. Kısaca, Müzik çalar. +Auxio, diğer müzik oynatıcılarda bulunan birçok gereksiz özellik olmadan hızlı, güvenilir bir kullanıcı arayüzüne ve deneyimine sahip yerel bir müzik çalardır. <a href="https://exoplayer.dev/">Exoplayer</a> üzerine inşa edilen Auxio, yerel MediaPlayer API'sini kullanan diğer uygulamalara kıyasla çok daha iyi bir dinleme deneyimine sahiptir. Kısaca, Müzik çalar. Özellikler @@ -16,4 +16,4 @@ Auxio, diğer müzik oynatıcılarda bulunan birçok gereksiz özellik olmadan h - Kulaklık otomatik oynatma - Boyutlarına otomatik olarak uyum sağlayan şık widget'lar - Tamamen özel ve çevrimdışı -- Yuvarlak albüm kapakları yok (İstemediğiniz sürece. İstediğiniz zaman yapabilirsiniz.) +- Yuvarlak albüm kapakları yok (İstediğiniz zaman açıp kapatabilirsiniz.) diff --git a/fastlane/metadata/android/zh-Hant/short_description.txt b/fastlane/metadata/android/zh-Hant/short_description.txt new file mode 100644 index 000000000..9cd838cde --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/short_description.txt @@ -0,0 +1 @@ +一款簡潔、理性的音樂播放器 From df98bb535f49b5adbd1d9557f194e16712c45b70 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 15 Jan 2023 20:30:45 -0700 Subject: [PATCH 32/55] list: rework diffing abstraction Make all adapters relying on diffing unified into a DiffAdapter superclass that can then accurately respond to the new UpdateInstructions data. UpdateInstructions is still not fully used everywhere, but will be soon. --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 2 +- .../oxycblt/auxio/{AuxioApp.kt => Auxio.kt} | 2 +- .../java/org/oxycblt/auxio/MainActivity.kt | 2 +- .../java/org/oxycblt/auxio/MainFragment.kt | 4 +- .../auxio/detail/AlbumDetailFragment.kt | 8 +- .../auxio/detail/ArtistDetailFragment.kt | 6 +- .../auxio/detail/GenreDetailFragment.kt | 6 +- .../detail/recycler/AlbumDetailAdapter.kt | 11 +- .../detail/recycler/ArtistDetailAdapter.kt | 10 +- .../auxio/detail/recycler/DetailAdapter.kt | 29 +-- .../detail/recycler/GenreDetailAdapter.kt | 10 +- .../auxio/home/list/AlbumListFragment.kt | 27 +-- .../auxio/home/list/ArtistListFragment.kt | 34 ++- .../auxio/home/list/GenreListFragment.kt | 35 ++- .../auxio/home/list/SongListFragment.kt | 36 ++- .../oxycblt/auxio/list/UpdateInstructions.kt | 23 +- .../auxio/list/recycler/DiffAdapter.kt | 71 ++++++ .../oxycblt/auxio/list/recycler/ListDiffer.kt | 224 ++++++++++++++++++ .../list/recycler/PlayingIndicatorAdapter.kt | 36 ++- .../recycler/SelectionIndicatorAdapter.kt | 12 +- .../music/extractor/MetadataExtractor.kt | 8 +- .../auxio/playback/PlaybackSettings.kt | 2 +- .../auxio/playback/queue/QueueAdapter.kt | 28 +-- .../auxio/playback/queue/QueueFragment.kt | 9 +- .../playback/state/PlaybackStateDatabase.kt | 2 +- .../org/oxycblt/auxio/search/SearchAdapter.kt | 24 +- .../oxycblt/auxio/search/SearchFragment.kt | 6 +- .../oxycblt/auxio/settings/AboutFragment.kt | 17 +- .../java/org/oxycblt/auxio/ui/UISettings.kt | 8 +- .../java/org/oxycblt/auxio/util/LangUtil.kt | 3 +- app/src/main/res/layout/fragment_main.xml | 2 + 32 files changed, 468 insertions(+), 230 deletions(-) rename app/src/main/java/org/oxycblt/auxio/{AuxioApp.kt => Auxio.kt} (98%) create mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/ListDiffer.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 24adc7035..b0a4f913c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added ability to edit previously played or currently playing items in the queue - Added support for date values formatted as "YYYYMMDD" - Pressing the button will now clear the current selection before navigating back +- Added support for non-standard `ARTISTS` tags #### What's Fixed - Fixed unreliable ReplayGain adjustment application in certain situations diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1c35add27..81daa45e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ InternalPlayer.Action.Open(intent.data ?: return false) - AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll + Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll else -> return false } playbackModel.startAction(action) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 67c80091c..794354aef 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -235,8 +235,8 @@ class MainFragment : tryHideAllSheets() } - // Since the listener is also reliant on the bottom sheets, we must also update it - // every frame. + // Since the navigation listener is also reliant on the bottom sheets, we must also update + // it every frame. callback.invalidateEnabled() return true 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 bbc6d07ee..c4eccd9c9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -82,7 +82,7 @@ class AlbumDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setAlbumUid(args.albumUid) collectImmediately(detailModel.currentAlbum, ::updateAlbum) - collectImmediately(detailModel.albumList, detailAdapter::submitList) + collectImmediately(detailModel.albumList, detailAdapter::diffList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -170,10 +170,10 @@ class AlbumDetailFragment : private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { - detailAdapter.setPlayingItem(song, isPlaying) + detailAdapter.setPlaying(song, isPlaying) } else { // Clear the ViewHolders if the mode isn't ALL_SONGS - detailAdapter.setPlayingItem(null, isPlaying) + detailAdapter.setPlaying(null, isPlaying) } } @@ -258,7 +258,7 @@ class AlbumDetailFragment : } private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } 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 66d25fe08..5d8c0f806 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -85,7 +85,7 @@ class ArtistDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtistUid(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateItem) - collectImmediately(detailModel.artistList, detailAdapter::submitList) + collectImmediately(detailModel.artistList, detailAdapter::diffList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -195,7 +195,7 @@ class ArtistDetailFragment : else -> null } - detailAdapter.setPlayingItem(playingItem, isPlaying) + detailAdapter.setPlaying(playingItem, isPlaying) } private fun handleNavigation(item: Music?) { @@ -234,7 +234,7 @@ class ArtistDetailFragment : } private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } 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 e72e2753c..b5701fcf6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -84,7 +84,7 @@ class GenreDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenreUid(args.genreUid) collectImmediately(detailModel.currentGenre, ::updateItem) - collectImmediately(detailModel.genreList, detailAdapter::submitList) + collectImmediately(detailModel.genreList, detailAdapter::diffList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -189,7 +189,7 @@ class GenreDetailFragment : if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { playingMusic = song } - detailAdapter.setPlayingItem(playingMusic, isPlaying) + detailAdapter.setPlaying(playingMusic, isPlaying) } private fun handleNavigation(item: Music?) { @@ -217,7 +217,7 @@ class GenreDetailFragment : } private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } 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 d6855c09f..6693ca754 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 @@ -57,7 +57,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene } override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support the Album header, sub-headers for each disc, and special album songs. is Album -> AlbumDetailViewHolder.VIEW_TYPE is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE @@ -75,7 +75,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item, listener) @@ -83,9 +83,12 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // The album and disc headers should be full-width in all configurations. - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Album || item is DiscHeader + val item = getItem(position) + return item is Album || item is DiscHeader } private companion object { 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 ceb7e9660..29c994c65 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 @@ -46,7 +46,7 @@ import org.oxycblt.auxio.util.inflater class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support an artist header, and special artist albums/songs. is Artist -> ArtistDetailViewHolder.VIEW_TYPE is Album -> ArtistAlbumViewHolder.VIEW_TYPE @@ -65,7 +65,7 @@ class ArtistDetailAdapter(private val listener: Listener) : override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) // Re-binding an item with new data and not just a changed selection/playing state. - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener) is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener) is Song -> (holder as ArtistSongViewHolder).bind(item, listener) @@ -73,9 +73,11 @@ class ArtistDetailAdapter(private val listener: Listener) : } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // Artist headers should be full-width in all configurations. - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Artist + return getItem(position) is Artist } private companion object { 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 7a365b58a..789ca2d19 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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable @@ -37,19 +36,19 @@ import org.oxycblt.auxio.util.inflater /** * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. * @param listener A [Listener] to bind interactions to. - * @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the * internal list. * @author Alexander Capehart (OxygenCobalt) */ abstract class DetailAdapter( private val listener: Listener<*>, - itemCallback: DiffUtil.ItemCallback -) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - // Safe to leak this since the listener will not fire during initialization - @Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback) + diffCallback: DiffUtil.ItemCallback +) : + SelectionIndicatorAdapter(ListDiffer.Async(diffCallback)), + AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Implement support for headers and sort headers is Header -> HeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE @@ -64,7 +63,7 @@ abstract class DetailAdapter( } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Header -> (holder as HeaderViewHolder).bind(item) is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener) } @@ -72,22 +71,10 @@ abstract class DetailAdapter( override fun isItemFullWidth(position: Int): Boolean { // Headers should be full-width in all configurations. - val item = differ.currentList[position] + val item = getItem(position) return item is Header || item is SortHeader } - override val currentList: List - get() = differ.currentList - - /** - * Asynchronously update the list with new items. Assumes that the list only contains data - * supported by the concrete [DetailAdapter] implementation. - * @param newList The new [Item]s for the adapter to display. - */ - fun submitList(newList: List) { - differ.submitList(newList) - } - /** An extended [SelectableListListener] for [DetailAdapter] implementations. */ interface Listener : SelectableListListener { // TODO: Split off into sub-listeners if a collapsing toolbar is implemented. 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 e956c5a91..93eed81c7 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 @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.inflater class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support the Genre header and generic Artist/Song items. There's nothing about // a genre that will make the artists/songs homogeneous, so it doesn't matter what we // use for their ViewHolders. @@ -64,7 +64,7 @@ class GenreDetailAdapter(private val listener: Listener) : override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Genre -> (holder as GenreDetailViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) is Song -> (holder as SongViewHolder).bind(item, listener) @@ -72,9 +72,11 @@ class GenreDetailAdapter(private val listener: Listener) : } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // Genre headers should be full-width in all configurations - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Genre + return getItem(position) is Genre } private companion object { 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 8cae8c396..e8911eea4 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 @@ -31,8 +31,8 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.AlbumViewHolder +import org.oxycblt.auxio.list.recycler.ListDiffer import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs @@ -67,7 +67,7 @@ class AlbumListFragment : } collectImmediately(homeModel.albumsList, albumAdapter::replaceList) - collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -130,9 +130,13 @@ class AlbumListFragment : openMusicMenu(anchor, R.menu.menu_album_actions, item) } + private fun updateSelection(selection: List) { + albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If an album is playing, highlight it within this adapter. - albumAdapter.setPlayingItem(parent as? Album, isPlaying) + albumAdapter.setPlaying(parent as? Album, isPlaying) } /** @@ -140,25 +144,14 @@ class AlbumListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class AlbumAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + SelectionIndicatorAdapter( + ListDiffer.Async(AlbumViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AlbumViewHolder.from(parent) override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Album]s. - * @param newList The new [Album]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 eaa0bfa2d..278f0d835 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 @@ -29,9 +29,10 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.ArtistViewHolder +import org.oxycblt.auxio.list.recycler.ListDiffer import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.library.Sort @@ -48,7 +49,7 @@ class ArtistListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = ArtistAdapter(this) + private val artistAdapter = ArtistAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -58,13 +59,13 @@ class ArtistListFragment : binding.homeRecycler.apply { id = R.id.home_artist_recycler - adapter = homeAdapter + adapter = artistAdapter popupProvider = this@ArtistListFragment listener = this@ArtistListFragment } - collectImmediately(homeModel.artistsList, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.artistsList, artistAdapter::replaceList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -107,9 +108,13 @@ class ArtistListFragment : openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + private fun updateSelection(selection: List) { + artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If an artist is playing, highlight it within this adapter. - homeAdapter.setPlayingItem(parent as? Artist, isPlaying) + artistAdapter.setPlaying(parent as? Artist, isPlaying) } /** @@ -117,25 +122,14 @@ class ArtistListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class ArtistAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + SelectionIndicatorAdapter( + ListDiffer.Async(ArtistViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ArtistViewHolder.from(parent) override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Artist]s. - * @param newList The new [Artist]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index d0989bd56..30109b43a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -29,9 +29,10 @@ import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.GenreViewHolder +import org.oxycblt.auxio.list.recycler.ListDiffer import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.library.Sort @@ -47,7 +48,7 @@ class GenreListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = GenreAdapter(this) + private val genreAdapter = GenreAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -57,13 +58,13 @@ class GenreListFragment : binding.homeRecycler.apply { id = R.id.home_genre_recycler - adapter = homeAdapter + adapter = genreAdapter popupProvider = this@GenreListFragment listener = this@GenreListFragment } - collectImmediately(homeModel.genresList, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.genresList, genreAdapter::replaceList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -106,9 +107,13 @@ class GenreListFragment : openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + private fun updateSelection(selection: List) { + genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If a genre is playing, highlight it within this adapter. - homeAdapter.setPlayingItem(parent as? Genre, isPlaying) + genreAdapter.setPlaying(parent as? Genre, isPlaying) } /** @@ -116,25 +121,13 @@ class GenreListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class GenreAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList - + SelectionIndicatorAdapter( + ListDiffer.Async(GenreViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = GenreViewHolder.from(parent) override fun onBindViewHolder(holder: GenreViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Genre]s. - * @param newList The new [Genre]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 da42fbd9a..e0ab09b87 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 @@ -30,9 +30,10 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.recycler.ListDiffer import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder -import org.oxycblt.auxio.list.recycler.SyncListDiffer +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -50,7 +51,7 @@ class SongListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = SongAdapter(this) + private val songAdapter = SongAdapter(this) // Save memory by re-using the same formatter and string builder when creating popup text private val formatterSb = StringBuilder(64) private val formatter = Formatter(formatterSb) @@ -63,13 +64,13 @@ class SongListFragment : binding.homeRecycler.apply { id = R.id.home_song_recycler - adapter = homeAdapter + adapter = songAdapter popupProvider = this@SongListFragment listener = this@SongListFragment } - collectImmediately(homeModel.songLists, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.songLists, songAdapter::replaceList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -136,12 +137,16 @@ class SongListFragment : openMusicMenu(anchor, R.menu.menu_song_actions, item) } + private fun updateSelection(selection: List) { + songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { if (parent == null) { - homeAdapter.setPlayingItem(song, isPlaying) + songAdapter.setPlaying(song, isPlaying) } else { // Ignore playback that is not from all songs - homeAdapter.setPlayingItem(null, isPlaying) + songAdapter.setPlaying(null, isPlaying) } } @@ -150,25 +155,14 @@ class SongListFragment : * @param listener An [SelectableListListener] to bind interactions to. */ private class SongAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + SelectionIndicatorAdapter( + ListDiffer.Async(SongViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SongViewHolder.from(parent) override fun onBindViewHolder(holder: SongViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Song]s. - * @param newList The new [Song]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt b/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt index 1c741aa56..e6a4868d4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/UpdateInstructions.kt @@ -1,3 +1,20 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.list /** @@ -12,8 +29,8 @@ enum class UpdateInstructions { DIFF, /** - * Synchronously remove the current list and replace it with a new one. This should be used - * for large diffs with that would cause erratic scroll behavior or in-efficiency. + * Synchronously remove the current list and replace it with a new one. This should be used for + * large diffs with that would cause erratic scroll behavior or in-efficiency. */ REPLACE -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt new file mode 100644 index 000000000..aff5e4d2c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.list.recycler + +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.list.UpdateInstructions + +/** + * A [RecyclerView.Adapter] with [ListDiffer] integration. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. + */ +abstract class DiffAdapter(differFactory: ListDiffer.Factory) : + RecyclerView.Adapter() { + private val differ = differFactory.new(@Suppress("LeakingThis") this) + + final override fun getItemCount() = differ.currentList.size + + /** The current list of [T] items. */ + val currentList: List + get() = differ.currentList + + /** + * Get a [T] item at the given position. + * @param at The position to get the item at. + * @throws IndexOutOfBoundsException If the index is not in the list bounds/ + */ + fun getItem(at: Int) = differ.currentList[at] + + /** + * Dynamically determine how to update the list based on the given [UpdateInstructions]. + * @param newList The new list of [T] items to show. + * @param instructions The [UpdateInstructions] specifying how to update the list. + */ + fun submitList(newList: List, instructions: UpdateInstructions) { + when (instructions) { + UpdateInstructions.DIFF -> diffList(newList) + UpdateInstructions.REPLACE -> replaceList(newList) + } + } + + /** + * Update this list using [DiffUtil]. This can simplify the work of updating the list, but can + * also cause erratic behavior. + * @param newList The new list of [T] items to show. + * @param onDone Callback that will be invoked when the update is completed, allowing means to + * reset the state. + */ + fun diffList(newList: List, onDone: () -> Unit = {}) = differ.diffList(newList, onDone) + + /** + * Visually replace the previous list with a new list. This is useful for large diffs that are + * too erratic for [diffList]. + * @param newList The new list of [T] items to show. + */ + fun replaceList(newList: List) = differ.replaceList(newList) +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ListDiffer.kt new file mode 100644 index 000000000..5dd924b2f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ListDiffer.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.list.recycler + +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import java.lang.reflect.Field +import org.oxycblt.auxio.util.lazyReflectedField +import org.oxycblt.auxio.util.requireIs + +/** + * List differ wrapper that provides more flexibility regarding the way lists are updated. + * @author Alexander Capehart (OxygenCobalt) + */ +interface ListDiffer { + /** The current list of [T] items. */ + val currentList: List + + /** + * Update this list using [DiffUtil]. This can simplify the work of updating the list, but can + * also cause erratic behavior. + * @param newList The new list of [T] items to show. + * @param onDone Callback that will be invoked when the update is completed, allowing means to + * reset the state. + */ + fun diffList(newList: List, onDone: () -> Unit = {}) + + /** + * Visually replace the previous list with a new list. This is useful for large diffs that are + * too erratic for [diffList]. + * @param newList The new list of [T] items to show. + */ + fun replaceList(newList: List) + + /** + * Defines the creation of new [ListDiffer] instances. Allows such [ListDiffer]s to be passed as + * arguments without reliance on a `this` [RecyclerView.Adapter]. + */ + abstract class Factory { + /** + * Create a new [ListDiffer] bound to the given [RecyclerView.Adapter]. + * @param adapter The [RecyclerView.Adapter] to bind to. + */ + abstract fun new(adapter: RecyclerView.Adapter<*>): ListDiffer + } + + /** + * Update lists on another thread. This is useful when large diffs are likely to occur in this + * list that would be exceedingly slow with [Blocking]. + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the + * internal list. + */ + class Async(private val diffCallback: DiffUtil.ItemCallback) : Factory() { + override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = + RealAsyncListDiffer(AdapterListUpdateCallback(adapter), diffCallback) + } + + /** + * Update lists on the main thread. This is useful when many small, discrete list diffs are + * likely to occur that would cause [Async] to get race conditions. + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the + * internal list. + */ + class Blocking(private val diffCallback: DiffUtil.ItemCallback) : Factory() { + override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = + RealBlockingListDiffer(AdapterListUpdateCallback(adapter), diffCallback) + } +} + +private class RealAsyncListDiffer( + private val updateCallback: ListUpdateCallback, + diffCallback: DiffUtil.ItemCallback +) : ListDiffer { + private val inner = + AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build()) + + override val currentList: List + get() = inner.currentList + + override fun diffList(newList: List, onDone: () -> Unit) { + inner.submitList(newList, onDone) + } + + override fun replaceList(newList: List) { + if (inner.currentList == newList) { + // Nothing to do. + return + } + // Do possibly the most idiotic thing possible and mutate the internal differ state + // so we don't have to deal with any disjoint list garbage. This should cancel any prior + // updates and correctly set up the list values while still allowing for the same + // visual animation as the blocking replaceList. + val oldListSize = inner.currentList.size + ASD_MAX_GENERATION_FIELD.set(inner, requireIs(ASD_MAX_GENERATION_FIELD.get(inner)) + 1) + ASD_MUTABLE_LIST_FIELD.set(inner, newList.ifEmpty { null }) + ASD_READ_ONLY_LIST_FIELD.set(inner, newList) + updateCallback.onRemoved(0, oldListSize) + updateCallback.onInserted(0, newList.size) + } + + private companion object { + val ASD_MAX_GENERATION_FIELD: Field by + lazyReflectedField(AsyncListDiffer::class, "mMaxScheduledGeneration") + val ASD_MUTABLE_LIST_FIELD: Field by lazyReflectedField(AsyncListDiffer::class, "mList") + val ASD_READ_ONLY_LIST_FIELD: Field by + lazyReflectedField(AsyncListDiffer::class, "mReadOnlyList") + } +} + +private class RealBlockingListDiffer( + private val updateCallback: ListUpdateCallback, + private val diffCallback: DiffUtil.ItemCallback +) : ListDiffer { + override var currentList = listOf() + + override fun diffList(newList: List, onDone: () -> Unit) { + if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) { + onDone() + return + } + + if (newList.isEmpty()) { + val oldListSize = currentList.size + currentList = listOf() + updateCallback.onRemoved(0, oldListSize) + onDone() + return + } + + if (currentList.isEmpty()) { + currentList = newList + updateCallback.onInserted(0, newList.size) + onDone() + return + } + + val oldList = currentList + val result = + DiffUtil.calculateDiff( + object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.areItemsTheSame(oldItem, newItem) + } else { + oldItem == null && newItem == null + } + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.areContentsTheSame(oldItem, newItem) + } else if (oldItem == null && newItem == null) { + true + } else { + throw AssertionError() + } + } + + override fun getChangePayload( + oldItemPosition: Int, + newItemPosition: Int + ): Any? { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.getChangePayload(oldItem, newItem) + } else { + throw AssertionError() + } + } + }) + + currentList = newList + result.dispatchUpdatesTo(updateCallback) + onDone() + } + + override fun replaceList(newList: List) { + if (currentList == newList) { + // Nothing to do. + return + } + + diffList(listOf()) + diffList(newList) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt index 9b07c339f..12da3c5d9 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt @@ -19,33 +19,27 @@ package org.oxycblt.auxio.list.recycler import android.view.View import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.logD /** * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @author Alexander Capehart (OxygenCobalt) */ -abstract class PlayingIndicatorAdapter : RecyclerView.Adapter() { +abstract class PlayingIndicatorAdapter( + differFactory: ListDiffer.Factory +) : DiffAdapter(differFactory) { // There are actually two states for this adapter: // - The currently playing item, which is usually marked as "selected" and becomes accented. // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is // marked as "playing" or not. - private var currentMusic: Music? = null + private var currentItem: T? = null private var isPlaying = false - /** - * The current list of the adapter. This is used to update items if the indicator state changes. - */ - abstract val currentList: List - - override fun getItemCount() = currentList.size - override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { // Only try to update the playing indicator if the ViewHolder supports it if (holder is ViewHolder) { - holder.updatePlayingIndicator(currentList[position] == currentMusic, isPlaying) + holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) } if (payloads.isEmpty()) { @@ -56,14 +50,14 @@ abstract class PlayingIndicatorAdapter : RecyclerV } /** * Update the currently playing item in the list. - * @param music The [Music] currently being played, or null if it is not being played. + * @param item The [T] currently being played, or null if it is not being played. * @param isPlaying Whether playback is ongoing or paused. */ - fun setPlayingItem(music: Music?, isPlaying: Boolean) { + fun setPlaying(item: T?, isPlaying: Boolean) { var updatedItem = false - if (currentMusic != music) { - val oldItem = currentMusic - currentMusic = music + if (currentItem != item) { + val oldItem = currentItem + currentItem = item // Remove the playing indicator from the old item if (oldItem != null) { @@ -76,8 +70,8 @@ abstract class PlayingIndicatorAdapter : RecyclerV } // Enable the playing indicator on the new item - if (music != null) { - val pos = currentList.indexOfFirst { it == music } + if (item != null) { + val pos = currentList.indexOfFirst { it == item } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { @@ -94,8 +88,8 @@ abstract class PlayingIndicatorAdapter : RecyclerV // We may have already called notifyItemChanged before when checking // if the item was being played, so in that case we don't need to // update again here. - if (!updatedItem && music != null) { - val pos = currentList.indexOfFirst { it == music } + if (!updatedItem && item != null) { + val pos = currentList.indexOfFirst { it == item } if (pos > -1) { notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt index 64036c7cd..6e402878d 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt @@ -24,11 +24,13 @@ import org.oxycblt.auxio.music.Music /** * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of * items. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @author Alexander Capehart (OxygenCobalt) */ -abstract class SelectionIndicatorAdapter : - PlayingIndicatorAdapter() { - private var selectedItems = setOf() +abstract class SelectionIndicatorAdapter( + differFactory: ListDiffer.Factory +) : PlayingIndicatorAdapter(differFactory) { + private var selectedItems = setOf() override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { super.onBindViewHolder(holder, position, payloads) @@ -39,9 +41,9 @@ abstract class SelectionIndicatorAdapter : /** * Update the list of selected items. - * @param items A list of selected [Music]. + * @param items A set of selected [T] items. */ - fun setSelectedItems(items: List) { + fun setSelected(items: Set) { val oldSelectedItems = selectedItems val newSelectedItems = items.toSet() if (newSelectedItems == oldSelectedItems) { 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 c2faf0eab..1d823faa3 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 @@ -64,8 +64,7 @@ class MetadataExtractor( /** * Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will * first delegate to the sub-extractors before parsing the metadata itself. - * @param emit A listener that will be invoked with every new [Song.Raw] instance when they are - * successfully loaded. + * @return A flow of [Song.Raw] instances. */ fun extract() = flow { while (true) { @@ -310,8 +309,9 @@ class Task(context: Context, private val raw: Song.Raw) { // Album artist comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } (comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } - (comments["albumartists_sort"] ?: comments["albumartistsort"]) - ?.let { raw.albumArtistSortNames = it } + (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { + raw.albumArtistSortNames = it + } // Genre comments["genre"]?.let { raw.genreNames = it } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 9e46e5946..c33e1ce1b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -199,7 +199,7 @@ interface PlaybackSettings : Settings { } } - companion object { + private companion object { const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" 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 d195b26e9..4922d6d35 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 @@ -27,9 +27,10 @@ import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemQueueSongBinding import org.oxycblt.auxio.list.EditableListListener +import org.oxycblt.auxio.list.recycler.DiffAdapter +import org.oxycblt.auxio.list.recycler.ListDiffer import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.* @@ -39,16 +40,13 @@ import org.oxycblt.auxio.util.* * @author Alexander Capehart (OxygenCobalt) */ class QueueAdapter(private val listener: EditableListListener) : - RecyclerView.Adapter() { - private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK) + DiffAdapter(ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) { // Since PlayingIndicator adapter relies on an item value, we cannot use it for this // adapter, as one item can appear at several points in the UI. Use a similar implementation // with an index value instead. private var currentIndex = 0 private var isPlaying = false - override fun getItemCount() = differ.currentList.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = QueueSongViewHolder.from(parent) @@ -61,31 +59,13 @@ class QueueAdapter(private val listener: EditableListListener) : payload: List ) { if (payload.isEmpty()) { - viewHolder.bind(differ.currentList[position], listener) + viewHolder.bind(getItem(position), listener) } viewHolder.isFuture = position > currentIndex viewHolder.updatePlayingIndicator(position == currentIndex, isPlaying) } - /** - * Synchronously update the list with new items. This is exceedingly slow for large diffs, so - * only use it for trivial updates. - * @param newList The new [Song]s for the adapter to display. - */ - fun submitList(newList: List) { - differ.submitList(newList) - } - - /** - * Replace the list with a new list. This is exceedingly slow for large diffs, so only use it - * for trivial updates. - * @param newList The new [Song]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) - } - /** * Set the position of the currently playing item in the queue. This will mark the item as * playing and any previous items as played. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 879a2879f..3d02e40aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -33,7 +33,6 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD /** * A [ViewBindingFragment] that displays an editable queue. @@ -102,13 +101,7 @@ class QueueFragment : ViewBindingFragment(), EditableListL // Replace or diff the queue depending on the type of change it is. val instructions = queueModel.instructions - if (instructions?.update == UpdateInstructions.REPLACE) { - logD("Replacing queue") - queueAdapter.replaceList(queue) - } else { - logD("Diffing queue") - queueAdapter.submitList(queue) - } + queueAdapter.submitList(queue, instructions?.update ?: UpdateInstructions.DIFF) // Update position in list (and thus past/future items) queueAdapter.setPosition(index, isPlaying) 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 1fbbde002..717750dbf 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 @@ -165,7 +165,7 @@ class PlaybackStateDatabase private constructor(context: Context) : fun write(state: SavedState?) { requireBackgroundThread() // Only bother saving a state if a song is actively playing from one. - // This is not the case with a null state or a state with an out-of-bounds index. + // This is not the case with a null state. if (state != null) { // Transform saved state into raw state, which can then be written to the database. val rawPlaybackState = 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 6653b8e24..edc8d90df 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.search import android.view.ViewGroup -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.recycler.* @@ -30,14 +29,11 @@ import org.oxycblt.auxio.music.* * @author Alexander Capehart (OxygenCobalt) */ class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - private val differ = AsyncListDiffer(this, DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + SelectionIndicatorAdapter(ListDiffer.Async(DIFF_CALLBACK)), + AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { is Song -> SongViewHolder.VIEW_TYPE is Album -> AlbumViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE @@ -57,7 +53,7 @@ class SearchAdapter(private val listener: SelectableListListener) : } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Song -> (holder as SongViewHolder).bind(item, listener) is Album -> (holder as AlbumViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) @@ -66,17 +62,7 @@ class SearchAdapter(private val listener: SelectableListListener) : } } - override fun isItemFullWidth(position: Int) = differ.currentList[position] is Header - - /** - * Asynchronously update the list with new items. Assumes that the list only contains supported - * data.. - * @param newList The new [Item]s for the adapter to display. - * @param callback A block called when the asynchronous update is completed. - */ - fun submitList(newList: List, callback: () -> Unit) { - differ.submitList(newList, callback) - } + override fun isItemFullWidth(position: Int) = getItem(position) is Header private companion object { /** A comparator that can be used with DiffUtil. */ 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 2c837a888..1fe364acb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -153,7 +153,7 @@ class SearchFragment : ListFragment() { // Don't show the RecyclerView (and it's stray overscroll effects) when there // are no results. binding.searchRecycler.isInvisible = results.isEmpty() - searchAdapter.submitList(results.toMutableList()) { + searchAdapter.diffList(results.toMutableList()) { // I would make it so that the position is only scrolled back to the top when // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. @@ -162,7 +162,7 @@ class SearchFragment : ListFragment() { } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - searchAdapter.setPlayingItem(parent ?: song, isPlaying) + searchAdapter.setPlaying(parent ?: song, isPlaying) } private fun handleNavigation(item: Music?) { @@ -180,7 +180,7 @@ class SearchFragment : ListFragment() { } private fun updateSelection(selected: List) { - searchAdapter.setSelectedItems(selected) + searchAdapter.setSelected(selected.toSet()) if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && selected.isNotEmpty()) { // Make selection of obscured items easier by hiding the keyboard. diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index bd6dfe9f3..aa94552d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -123,14 +123,15 @@ class AboutFragment : ViewBindingFragment() { if (pkgName == "android") { // No default browser [Must open app chooser, may not be supported] openAppChooser(browserIntent) - } else try { - browserIntent.setPackage(pkgName) - startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // Not a browser but an app chooser - browserIntent.setPackage(null) - openAppChooser(browserIntent) - } + } else + try { + browserIntent.setPackage(pkgName) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser + browserIntent.setPackage(null) + openAppChooser(browserIntent) + } } else { // No app installed to open the link context.showToast(R.string.err_no_app) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt index dedc5efc4..5e9de4b83 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -75,10 +75,8 @@ interface UISettings : Settings { var accent = sharedPreferences.getInt(OLD_KEY_ACCENT3, 5) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Accents were previously frozen as soon as the OS was updated to android - // twelve, - // as dynamic colors were enabled by default. This is no longer the case, so we - // need - // to re-update the setting to dynamic colors here. + // twelve, as dynamic colors were enabled by default. This is no longer the + // case, so we need to re-update the setting to dynamic colors here. accent = 16 } @@ -96,7 +94,7 @@ interface UISettings : Settings { } } - companion object { + private companion object { const val OLD_KEY_ACCENT3 = "auxio_accent" } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index dfaca9127..caf2f811e 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -39,7 +39,8 @@ fun unlikelyToBeNull(value: T?) = * @return A data casted to [T]. * @throws IllegalStateException If the data cannot be casted to [T]. */ -inline fun requireIs(data: Any): T { +inline fun requireIs(data: Any?): T { + requireNotNull(data) { "Unexpected datatype: null" } check(data is T) { "Unexpected datatype: ${data::class.simpleName}" } return data } diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index abc7469ee..6c22299d1 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -8,6 +8,8 @@ android:background="?attr/colorSurface" android:transitionGroup="true"> + + Date: Sun, 15 Jan 2023 20:32:46 -0700 Subject: [PATCH 33/55] list: remove synclistdiffer Forgot to remove this now useless class. --- .../auxio/list/recycler/DiffAdapter.kt | 2 +- .../auxio/list/recycler/SyncListDiffer.kt | 141 ------------------ 2 files changed, 1 insertion(+), 142 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt index aff5e4d2c..e6595b325 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DiffAdapter.kt @@ -54,7 +54,7 @@ abstract class DiffAdapter(differFactory: ListD } /** - * Update this list using [DiffUtil]. This can simplify the work of updating the list, but can + * Update this list using DiffUtil. This can simplify the work of updating the list, but can * also cause erratic behavior. * @param newList The new list of [T] items to show. * @param onDone Callback that will be invoked when the update is completed, allowing means to diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt deleted file mode 100644 index 4b47c22c3..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.list.recycler - -import androidx.recyclerview.widget.AdapterListUpdateCallback -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView - -/** - * A list differ that operates synchronously. This can help resolve some shortcomings with - * AsyncListDiffer, at the cost of performance. Derived from Material Files: - * https://github.com/zhanghai/MaterialFiles - * @author Hai Zhang, Alexander Capehart (OxygenCobalt) - */ -class SyncListDiffer( - adapter: RecyclerView.Adapter<*>, - private val diffCallback: DiffUtil.ItemCallback -) { - private val updateCallback = AdapterListUpdateCallback(adapter) - - var currentList: List = emptyList() - private set(newList) { - if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) { - return - } - - if (newList.isEmpty()) { - val oldListSize = currentList.size - field = emptyList() - updateCallback.onRemoved(0, oldListSize) - return - } - - if (currentList.isEmpty()) { - field = newList - updateCallback.onInserted(0, newList.size) - return - } - - val oldList = currentList - val result = - DiffUtil.calculateDiff( - object : DiffUtil.Callback() { - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.areItemsTheSame(oldItem, newItem) - } else { - oldItem == null && newItem == null - } - } - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.areContentsTheSame(oldItem, newItem) - } else if (oldItem == null && newItem == null) { - true - } else { - throw AssertionError() - } - } - - override fun getChangePayload( - oldItemPosition: Int, - newItemPosition: Int - ): Any? { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.getChangePayload(oldItem, newItem) - } else { - throw AssertionError() - } - } - }) - - field = newList - result.dispatchUpdatesTo(updateCallback) - } - - /** - * Submit a list like AsyncListDiffer. This is exceedingly slow for large diffs, so only use it - * if the changes are trivial. - * @param newList The list to update to. - */ - fun submitList(newList: List) { - if (newList == currentList) { - // Nothing to do. - return - } - - currentList = newList - } - - /** - * Replace this list with a new list. This is good for large diffs that are too slow to update - * synchronously, but too chaotic to update asynchronously. - * @param newList The list to update to. - */ - fun replaceList(newList: List) { - if (newList == currentList) { - // Nothing to do. - return - } - - currentList = emptyList() - currentList = newList - } -} From a0aaec98d0fdfe8499ee0299121e9cabf6a0985a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Jan 2023 09:33:20 -0700 Subject: [PATCH 34/55] music: extend non-standard artists to id3v2 Just pre-emptively add support for TXXX variations of the non-standard vorbis artist fields to the ID3v2 parser. I'd imagine the naming convention will be similar between them, so why not. --- .../org/oxycblt/auxio/detail/ReadOnlyTextInput.kt | 2 +- .../auxio/music/extractor/MetadataExtractor.kt | 6 +++--- .../org/oxycblt/auxio/playback/queue/QueueAdapter.kt | 1 + app/src/main/res/layout/dialog_music_dirs.xml | 12 ++++++++++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index 327038255..f0e0924e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.R * * Adapted from Material Files: https://github.com/zhanghai/MaterialFiles * - * @author Alexander Capehart (OxygenCobalt) + * @author Hai Zhang, Alexander Capehart (OxygenCobalt) */ class ReadOnlyTextInput @JvmOverloads 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 1d823faa3..2ac6e26c3 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 @@ -213,12 +213,12 @@ class Task(context: Context, private val raw: Song.Raw) { // Artist textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } - textFrames["TSOP"]?.let { raw.artistSortNames = it } + (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it } // Album artist textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } - textFrames["TPE2"]?.let { raw.albumArtistNames = it } - textFrames["TSO2"]?.let { raw.albumArtistSortNames = it } + (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } + (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { raw.albumArtistSortNames = it } // Genre textFrames["TCON"]?.let { raw.genreNames = it } 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 4922d6d35..1cec07ed3 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 @@ -167,6 +167,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fun from(parent: View) = QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) + // TODO: This is not good enough, I need to compare item indices as well. /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK } diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index a8eb3e006..2bad59793 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -12,6 +12,18 @@ android:layout_height="match_parent" android:orientation="vertical"> + + + + Reload the music library whenever it changes (requires persistent notification) Music folders Manage where music should be loaded from + Folders Mode From 6e0292998295a76fcdcc8538961d6714960c12bb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Jan 2023 10:18:55 -0700 Subject: [PATCH 35/55] music: reorganize music folders dialog Reorganize the music folders dialog to be more visually straightforward than prior, primarily by grouping the folder elements into the same visual region. Resolves #318. --- CHANGELOG.md | 1 + .../music/extractor/MetadataExtractor.kt | 4 +- .../auxio/music/storage/MusicDirsDialog.kt | 17 ++- .../org/oxycblt/auxio/util/ContextUtil.kt | 2 + app/src/main/res/drawable/ic_add_24.xml | 11 ++ app/src/main/res/layout/dialog_music_dirs.xml | 114 +++++++++++------- app/src/main/res/layout/item_music_dir.xml | 2 + 7 files changed, 97 insertions(+), 54 deletions(-) create mode 100644 app/src/main/res/drawable/ic_add_24.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a4f913c..81c82879b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Added support for date values formatted as "YYYYMMDD" - Pressing the button will now clear the current selection before navigating back - Added support for non-standard `ARTISTS` tags +- Reworked music folders dialog to be more coherent #### What's Fixed - Fixed unreliable ReplayGain adjustment application in certain situations 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 2ac6e26c3..b80b45882 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 @@ -218,7 +218,9 @@ class Task(context: Context, private val raw: Song.Raw) { // Album artist textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } - (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { raw.albumArtistSortNames = it } + (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { + raw.albumArtistSortNames = it + } // Genre textFrames["TCON"]?.let { raw.genreNames = it } diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index e9cb75d42..3db2c0dff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -26,6 +26,7 @@ import android.view.LayoutInflater import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.core.view.ViewCompat import androidx.core.view.isVisible import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R @@ -50,10 +51,8 @@ class MusicDirsDialog : DialogMusicDirsBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { - // Don't set the click listener here, we do some custom magic in onCreateView instead. builder .setTitle(R.string.set_dirs) - .setNeutralButton(R.string.lbl_add, null) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> val settings = MusicSettings.from(requireContext()) @@ -74,13 +73,9 @@ class MusicDirsDialog : registerForActivityResult( ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs) - // Now that the dialog exists, we get the view manually when the dialog is shown - // and override its click listener so that the dialog does not auto-dismiss when we - // click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former - // and the app from crashing in the latter. - requireDialog().setOnShowListener { - val dialog = it as AlertDialog - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + binding.dirsAdd.apply { + ViewCompat.setTooltipText(this, contentDescription) + setOnClickListener { logD("Opening launcher") val launcher = requireNotNull(openDocumentTreeLauncher) { @@ -182,8 +177,12 @@ class MusicDirsDialog : private fun updateMode() { val binding = requireBinding() if (isUiModeInclude(binding)) { + binding.dirsModeExclude.icon = null + binding.dirsModeInclude.setIconResource(R.drawable.ic_check_24) binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc) } else { + binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24) + binding.dirsModeInclude.icon = null binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc) } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index de624a85d..3ba0fee00 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.util import android.app.PendingIntent +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.res.ColorStateList @@ -26,6 +27,7 @@ import android.os.Build import android.util.TypedValue import android.view.LayoutInflater import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.AttrRes import androidx.annotation.ColorRes import androidx.annotation.DimenRes diff --git a/app/src/main/res/drawable/ic_add_24.xml b/app/src/main/res/drawable/ic_add_24.xml new file mode 100644 index 000000000..c056f550e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index 2bad59793..8d234db5f 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -4,60 +4,27 @@ xmlns:tools="http://schemas.android.com/tools" style="@style/Widget.Auxio.Dialog.NestedScrollView" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" android:orientation="vertical"> - + android:layout_height="match_parent"> - - - - - - - - + android:text="@string/set_dirs_mode" + app:layout_constraintTop_toTopOf="parent" /> -