From a1efb0c34a2e2aad571bc6f63386c10423e9e5dc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Jun 2023 20:31:15 -0600 Subject: [PATCH 001/127] ui: rename menu resources Switch from redundant menu_* prefixes to more use-specific prefixes used elsewhere. --- .../org/oxycblt/auxio/detail/AlbumDetailFragment.kt | 6 +++--- .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 8 ++++---- .../org/oxycblt/auxio/detail/GenreDetailFragment.kt | 8 ++++---- .../org/oxycblt/auxio/detail/PlaylistDetailFragment.kt | 4 ++-- .../org/oxycblt/auxio/home/list/AlbumListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/ArtistListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/GenreListFragment.kt | 2 +- .../oxycblt/auxio/home/list/PlaylistListFragment.kt | 2 +- .../org/oxycblt/auxio/home/list/SongListFragment.kt | 2 +- .../java/org/oxycblt/auxio/search/SearchFragment.kt | 10 +++++----- .../main/res/layout-h480dp/fragment_playback_panel.xml | 2 +- .../res/layout-sw600dp/fragment_playback_panel.xml | 2 +- app/src/main/res/layout/fragment_detail.xml | 4 ++-- app/src/main/res/layout/fragment_home.xml | 4 ++-- app/src/main/res/layout/fragment_playback_panel.xml | 2 +- app/src/main/res/layout/fragment_search.xml | 4 ++-- .../res/menu/{menu_album_detail.xml => item_album.xml} | 0 ...menu_album_song_actions.xml => item_album_song.xml} | 0 ..._artist_album_actions.xml => item_artist_album.xml} | 0 ...nu_artist_song_actions.xml => item_artist_song.xml} | 0 .../menu/{menu_parent_actions.xml => item_parent.xml} | 0 .../{menu_playlist_actions.xml => item_playlist.xml} | 0 ...laylist_song_actions.xml => item_playlist_song.xml} | 0 .../res/menu/{menu_song_actions.xml => item_song.xml} | 0 .../res/menu/{menu_album_sort.xml => sort_album.xml} | 0 .../res/menu/{menu_artist_sort.xml => sort_artist.xml} | 0 .../res/menu/{menu_genre_sort.xml => sort_genre.xml} | 0 .../menu/{menu_album_actions.xml => toolbar_album.xml} | 6 ------ .../menu/{menu_edit_actions.xml => toolbar_edit.xml} | 0 .../main/res/menu/{menu_home.xml => toolbar_home.xml} | 0 .../{menu_parent_detail.xml => toolbar_parent.xml} | 0 .../menu/{menu_playback.xml => toolbar_playback.xml} | 0 .../{menu_playlist_detail.xml => toolbar_playlist.xml} | 0 .../res/menu/{menu_search.xml => toolbar_search.xml} | 0 ...enu_selection_actions.xml => toolbar_selection.xml} | 0 35 files changed, 32 insertions(+), 38 deletions(-) rename app/src/main/res/menu/{menu_album_detail.xml => item_album.xml} (100%) rename app/src/main/res/menu/{menu_album_song_actions.xml => item_album_song.xml} (100%) rename app/src/main/res/menu/{menu_artist_album_actions.xml => item_artist_album.xml} (100%) rename app/src/main/res/menu/{menu_artist_song_actions.xml => item_artist_song.xml} (100%) rename app/src/main/res/menu/{menu_parent_actions.xml => item_parent.xml} (100%) rename app/src/main/res/menu/{menu_playlist_actions.xml => item_playlist.xml} (100%) rename app/src/main/res/menu/{menu_playlist_song_actions.xml => item_playlist_song.xml} (100%) rename app/src/main/res/menu/{menu_song_actions.xml => item_song.xml} (100%) rename app/src/main/res/menu/{menu_album_sort.xml => sort_album.xml} (100%) rename app/src/main/res/menu/{menu_artist_sort.xml => sort_artist.xml} (100%) rename app/src/main/res/menu/{menu_genre_sort.xml => sort_genre.xml} (100%) rename app/src/main/res/menu/{menu_album_actions.xml => toolbar_album.xml} (76%) rename app/src/main/res/menu/{menu_edit_actions.xml => toolbar_edit.xml} (100%) rename app/src/main/res/menu/{menu_home.xml => toolbar_home.xml} (100%) rename app/src/main/res/menu/{menu_parent_detail.xml => toolbar_parent.xml} (100%) rename app/src/main/res/menu/{menu_playback.xml => toolbar_playback.xml} (100%) rename app/src/main/res/menu/{menu_playlist_detail.xml => toolbar_playlist.xml} (100%) rename app/src/main/res/menu/{menu_search.xml => toolbar_search.xml} (100%) rename app/src/main/res/menu/{menu_selection_actions.xml => toolbar_selection.xml} (100%) 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 8bf635e5e..1197a5f78 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -101,7 +101,7 @@ class AlbumDetailFragment : // --- UI SETUP -- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_album_detail) + inflateMenu(R.menu.toolbar_album) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@AlbumDetailFragment) } @@ -183,7 +183,7 @@ class AlbumDetailFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_song_actions, item) + openMusicMenu(anchor, R.menu.item_album_song, item) } override fun onPlay() { @@ -195,7 +195,7 @@ class AlbumDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_album_sort) { + openMenu(anchor, R.menu.sort_album) { // Select the corresponding sort mode option val sort = detailModel.albumSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 86208424b..b281ec397 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -99,7 +99,7 @@ class ArtistDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.toolbar_parent) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) } @@ -194,8 +194,8 @@ class ArtistDetailFragment : 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) + is Song -> openMusicMenu(anchor, R.menu.item_artist_song, item) + is Album -> openMusicMenu(anchor, R.menu.item_artist_album, item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -209,7 +209,7 @@ class ArtistDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_artist_sort) { + openMenu(anchor, R.menu.sort_artist) { // Select the corresponding sort mode option val sort = detailModel.artistSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 a2d2e2cd9..a8646fe24 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -97,7 +97,7 @@ class GenreDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) + inflateMenu(R.menu.toolbar_parent) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) } @@ -192,8 +192,8 @@ class GenreDetailFragment : override fun onOpenMenu(item: Music, anchor: View) { when (item) { - is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) + is Artist -> openMusicMenu(anchor, R.menu.item_parent, item) + is Song -> openMusicMenu(anchor, R.menu.item_song, item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -207,7 +207,7 @@ class GenreDetailFragment : } override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_genre_sort) { + openMenu(anchor, R.menu.sort_genre) { // Select the corresponding sort mode option val sort = detailModel.genreSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index dc13ef831..b444abfdb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -102,7 +102,7 @@ class PlaylistDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_playlist_detail) + inflateMenu(R.menu.toolbar_playlist) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) } @@ -235,7 +235,7 @@ class PlaylistDetailFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item) + openMusicMenu(anchor, R.menu.item_playlist_song, item) } override fun onPlay() { 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 b4aac9121..f6ed98f88 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 @@ -142,7 +142,7 @@ class AlbumListFragment : } override fun onOpenMenu(item: Album, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_actions, item) + openMusicMenu(anchor, R.menu.item_album, item) } private fun updateAlbums(albums: List) { 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 b66c6e965..86fdc3483 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 @@ -118,7 +118,7 @@ class ArtistListFragment : } override fun onOpenMenu(item: Artist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.item_parent, item) } private fun updateArtists(artists: List) { 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 d751e3699..46c26f689 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 @@ -117,7 +117,7 @@ class GenreListFragment : } override fun onOpenMenu(item: Genre, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + openMusicMenu(anchor, R.menu.item_parent, item) } private fun updateGenres(genres: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index 405dbe312..cef433bde 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -115,7 +115,7 @@ class PlaylistListFragment : } override fun onOpenMenu(item: Playlist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_actions, item) + openMusicMenu(anchor, R.menu.item_playlist, item) } private fun updatePlaylists(playlists: List) { 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 d827adbf7..b22366658 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 @@ -143,7 +143,7 @@ class SongListFragment : } override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_song_actions, item) + openMusicMenu(anchor, R.menu.item_song, item) } private fun updateSongs(songs: List) { 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 4efc4c704..ff41e9910 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -181,11 +181,11 @@ class SearchFragment : ListFragment() { 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_parent_actions, item) - is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, item) + is Song -> openMusicMenu(anchor, R.menu.item_song, item) + is Album -> openMusicMenu(anchor, R.menu.item_album, item) + is Artist -> openMusicMenu(anchor, R.menu.item_parent, item) + is Genre -> openMusicMenu(anchor, R.menu.item_parent, item) + is Playlist -> openMusicMenu(anchor, R.menu.item_playlist, item) } } diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index f2919eacc..263bf2a7d 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index 9fa7ff413..c673dd8ca 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index a272ca07f..1dfcf82b9 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -33,7 +33,7 @@ android:clickable="true" android:focusable="true" app:navigationIcon="@drawable/ic_close_24" - app:menu="@menu/menu_selection_actions" /> + app:menu="@menu/toolbar_selection" /> + app:menu="@menu/toolbar_edit" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 712509a65..049256481 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_scrollFlags="scroll|enterAlways" - app:menu="@menu/menu_home" + app:menu="@menu/toolbar_home" app:title="@string/info_app_name" /> + app:menu="@menu/toolbar_selection" /> diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 313c018f0..74f106903 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 2aac496d0..d8eba32d7 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -21,7 +21,7 @@ android:id="@+id/search_normal_toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:menu="@menu/menu_search" + app:menu="@menu/toolbar_search" app:navigationIcon="@drawable/ic_back_24"> + app:menu="@menu/toolbar_selection" /> diff --git a/app/src/main/res/menu/menu_album_detail.xml b/app/src/main/res/menu/item_album.xml similarity index 100% rename from app/src/main/res/menu/menu_album_detail.xml rename to app/src/main/res/menu/item_album.xml diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/item_album_song.xml similarity index 100% rename from app/src/main/res/menu/menu_album_song_actions.xml rename to app/src/main/res/menu/item_album_song.xml diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/item_artist_album.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_album_actions.xml rename to app/src/main/res/menu/item_artist_album.xml diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/item_artist_song.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_song_actions.xml rename to app/src/main/res/menu/item_artist_song.xml diff --git a/app/src/main/res/menu/menu_parent_actions.xml b/app/src/main/res/menu/item_parent.xml similarity index 100% rename from app/src/main/res/menu/menu_parent_actions.xml rename to app/src/main/res/menu/item_parent.xml diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/item_playlist.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_actions.xml rename to app/src/main/res/menu/item_playlist.xml diff --git a/app/src/main/res/menu/menu_playlist_song_actions.xml b/app/src/main/res/menu/item_playlist_song.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_song_actions.xml rename to app/src/main/res/menu/item_playlist_song.xml diff --git a/app/src/main/res/menu/menu_song_actions.xml b/app/src/main/res/menu/item_song.xml similarity index 100% rename from app/src/main/res/menu/menu_song_actions.xml rename to app/src/main/res/menu/item_song.xml diff --git a/app/src/main/res/menu/menu_album_sort.xml b/app/src/main/res/menu/sort_album.xml similarity index 100% rename from app/src/main/res/menu/menu_album_sort.xml rename to app/src/main/res/menu/sort_album.xml diff --git a/app/src/main/res/menu/menu_artist_sort.xml b/app/src/main/res/menu/sort_artist.xml similarity index 100% rename from app/src/main/res/menu/menu_artist_sort.xml rename to app/src/main/res/menu/sort_artist.xml diff --git a/app/src/main/res/menu/menu_genre_sort.xml b/app/src/main/res/menu/sort_genre.xml similarity index 100% rename from app/src/main/res/menu/menu_genre_sort.xml rename to app/src/main/res/menu/sort_genre.xml diff --git a/app/src/main/res/menu/menu_album_actions.xml b/app/src/main/res/menu/toolbar_album.xml similarity index 76% rename from app/src/main/res/menu/menu_album_actions.xml rename to app/src/main/res/menu/toolbar_album.xml index 6f9f28aff..7cc2b4b79 100644 --- a/app/src/main/res/menu/menu_album_actions.xml +++ b/app/src/main/res/menu/toolbar_album.xml @@ -1,11 +1,5 @@ - - diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/toolbar_edit.xml similarity index 100% rename from app/src/main/res/menu/menu_edit_actions.xml rename to app/src/main/res/menu/toolbar_edit.xml diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/toolbar_home.xml similarity index 100% rename from app/src/main/res/menu/menu_home.xml rename to app/src/main/res/menu/toolbar_home.xml diff --git a/app/src/main/res/menu/menu_parent_detail.xml b/app/src/main/res/menu/toolbar_parent.xml similarity index 100% rename from app/src/main/res/menu/menu_parent_detail.xml rename to app/src/main/res/menu/toolbar_parent.xml diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/toolbar_playback.xml similarity index 100% rename from app/src/main/res/menu/menu_playback.xml rename to app/src/main/res/menu/toolbar_playback.xml diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/toolbar_playlist.xml similarity index 100% rename from app/src/main/res/menu/menu_playlist_detail.xml rename to app/src/main/res/menu/toolbar_playlist.xml diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/toolbar_search.xml similarity index 100% rename from app/src/main/res/menu/menu_search.xml rename to app/src/main/res/menu/toolbar_search.xml diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/toolbar_selection.xml similarity index 100% rename from app/src/main/res/menu/menu_selection_actions.xml rename to app/src/main/res/menu/toolbar_selection.xml From 6dc2892eb90ce359cd87c36b3b388350ef348c6d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 10:45:32 -0600 Subject: [PATCH 002/127] home: fix settings anim Fix mismatched navigation animations when going to settings and back. --- .../java/org/oxycblt/auxio/MainFragment.kt | 7 ---- .../org/oxycblt/auxio/home/HomeFragment.kt | 39 ++++++++++++------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 9b6b47a08..5ae68fc78 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -34,7 +34,6 @@ import androidx.navigation.findNavController import com.google.android.material.R as MR import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable -import com.google.android.material.transition.MaterialFadeThrough import dagger.hilt.android.AndroidEntryPoint import kotlin.math.max import kotlin.math.min @@ -79,12 +78,6 @@ class MainFragment : private var elevationNormal = 0f private var initialNavDestinationChange = true - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - exitTransition = MaterialFadeThrough() - } - override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { 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 7aa4dbe5f..4a878826f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -38,6 +38,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.appbar.AppBarLayout import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint import java.lang.reflect.Field @@ -100,9 +101,11 @@ class HomeFragment : // Orientation change will wipe whatever transition we were using prior, which will // result in no transition when the user navigates back. Make sure we re-initialize // our transitions. - val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1) - if (axis > -1) { - setupAxisTransitions(axis) + val id = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -2) + when (id) { + -2 -> {} + -1 -> applyFadeTransition() + else -> applyAxisTransition(id) } } } @@ -179,9 +182,9 @@ class HomeFragment : } override fun onSaveInstanceState(outState: Bundle) { - val enter = enterTransition - if (enter is MaterialSharedAxis) { - outState.putInt(KEY_LAST_TRANSITION_AXIS, enter.axis) + when (val transition = enterTransition) { + is MaterialFadeThrough -> outState.putInt(KEY_LAST_TRANSITION_ID, -1) + is MaterialSharedAxis -> outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis) } super.onSaveInstanceState(outState) @@ -214,17 +217,19 @@ class HomeFragment : // Handle main actions (Search, Settings, About) R.id.action_search -> { logD("Navigating to search") - setupAxisTransitions(MaterialSharedAxis.Z) + applyAxisTransition(MaterialSharedAxis.Z) findNavController().navigateSafe(HomeFragmentDirections.search()) true } R.id.action_settings -> { logD("Navigating to preferences") + applyFadeTransition() findNavController().navigateSafe(HomeFragmentDirections.preferences()) true } R.id.action_about -> { logD("Navigating to about") + applyFadeTransition() findNavController().navigateSafe(HomeFragmentDirections.about()) true } @@ -537,7 +542,7 @@ class HomeFragment : // fragment should be launched otherwise. is Show.SongAlbumDetails -> { logD("Navigating to the album of ${show.song}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid)) } @@ -546,14 +551,14 @@ class HomeFragment : // detail fragment. is Show.AlbumDetails -> { logD("Navigating to ${show.album}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid)) } // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid)) } is Show.SongArtistDetails -> { @@ -566,10 +571,12 @@ class HomeFragment : } is Show.GenreDetails -> { logD("Navigating to ${show.genre}") + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid)) } is Show.PlaylistDetails -> { logD("Navigating to ${show.playlist}") + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid)) } @@ -591,7 +598,7 @@ class HomeFragment : } } - private fun setupAxisTransitions(axis: Int) { + private fun applyAxisTransition(axis: Int) { // Sanity check to avoid in-correct axis transitions check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) { "Not expecting Y axis transition" @@ -603,6 +610,13 @@ class HomeFragment : reenterTransition = MaterialSharedAxis(axis, false) } + private fun applyFadeTransition() { + enterTransition = MaterialFadeThrough() + returnTransition = MaterialFadeThrough() + exitTransition = MaterialFadeThrough() + reenterTransition = MaterialFadeThrough() + } + /** * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance. * @@ -630,7 +644,6 @@ class HomeFragment : private companion object { val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") - const val KEY_LAST_TRANSITION_AXIS = - BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" + const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" } } From c1158b1a07077ae4ee557b17743aa46cbe7fe188 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 14:24:26 -0600 Subject: [PATCH 003/127] list: add menu dialog framework Add a dialog that shows menu information as a bottom sheet instead of as a PopupMenu. This did not take as much finessing of BottomSheetBehavior as the main playback UI framework did, just some style redefinitions. --- .../oxycblt/auxio/detail/SongDetailDialog.kt | 6 +- .../auxio/detail/picker/ShowArtistDialog.kt | 6 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 7 +- .../auxio/list/menu/MenuDialogFragment.kt | 55 +++++++++ .../auxio/list/menu/MenuOptionAdapter.kt | 73 ++++++++++++ .../auxio/list/recycler/DialogRecyclerView.kt | 9 ++ .../oxycblt/auxio/music/fs/MusicDirsDialog.kt | 4 +- .../auxio/music/metadata/SeparatorsDialog.kt | 8 +- .../auxio/music/picker/AddToPlaylistDialog.kt | 4 +- .../music/picker/DeletePlaylistDialog.kt | 6 +- .../auxio/music/picker/NewPlaylistDialog.kt | 4 +- .../music/picker/RenamePlaylistDialog.kt | 4 +- .../playback/picker/PlayFromArtistDialog.kt | 6 +- .../playback/picker/PlayFromGenreDialog.kt | 6 +- .../replaygain/PreAmpCustomizeDialog.kt | 7 +- .../ViewBindingBottomSheetDialogFragment.kt | 111 ++++++++++++++++++ ...t => ViewBindingMaterialDialogFragment.kt} | 7 +- .../auxio/ui/accent/AccentCustomizeDialog.kt | 6 +- app/src/main/res/layout/dialog_menu.xml | 24 ++++ app/src/main/res/layout/item_menu_option.xml | 12 ++ app/src/main/res/navigation/main.xml | 9 ++ app/src/main/res/values/styles_core.xml | 2 + app/src/main/res/values/styles_ui.xml | 22 ++++ 23 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt rename app/src/main/java/org/oxycblt/auxio/ui/{ViewBindingDialogFragment.kt => ViewBindingMaterialDialogFragment.kt} (95%) create mode 100644 app/src/main/res/layout/dialog_menu.xml create mode 100644 app/src/main/res/layout/item_menu_option.xml diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index f7b293a06..e63c630ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -38,18 +38,18 @@ import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.formatDurationMs -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that shows information about a Song. + * A [ViewBindingMaterialDialogFragment] that shows information about a Song. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class SongDetailDialog : ViewBindingDialogFragment() { +class SongDetailDialog : ViewBindingMaterialDialogFragment() { private val detailModel: DetailViewModel by activityViewModels() // Information about what song to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an song. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt index a98dfb162..2e7ef1ba1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt @@ -33,17 +33,17 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately /** - * A picker [ViewBindingDialogFragment] intended for when the [Artist] to show is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class ShowArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val detailModel: DetailViewModel by activityViewModels() private val pickerModel: NavigationPickerViewModel by viewModels() // Information about what artists to show choices for is initially within the navigation 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 c7dadd8d2..fd6b36ae7 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 @@ -30,17 +30,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.list.EditClickListListener -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. + * A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab] + * configuration. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class TabCustomizeDialog : - ViewBindingDialogFragment(), EditClickListListener { + ViewBindingMaterialDialogFragment(), EditClickListListener { private val tabAdapter = TabAdapter(this) private var touchHelper: ItemTouchHelper? = null @Inject lateinit var homeSettings: HomeSettings diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt new file mode 100644 index 000000000..5129857dc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuDialogFragment.kt is part of Auxio. + * + * 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.menu + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.view.children +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMenuBinding +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment + +/** + * A [ViewBindingBottomSheetDialogFragment] that displays basic music information and + * a series of options. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuDialogFragment : ViewBindingBottomSheetDialogFragment() { + private val menuAdapter = MenuOptionAdapter() + + override fun onCreateBinding(inflater: LayoutInflater) = DialogMenuBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMenuBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.menuRecycler.apply { + adapter = menuAdapter + itemAnimator = null + } + + // Avoid having to use a dummy view and rely on what AndroidX Toolbar uses. + @SuppressLint("RestrictedApi") val builder = MenuBuilder(requireContext()) + MenuInflater(requireContext()).inflate(R.menu.item_song, builder) + menuAdapter.update(builder.children.toList(), UpdateInstructions.Diff) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt new file mode 100644 index 000000000..a9e23f11e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuOptionAdapter.kt is part of Auxio. + * + * 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.menu + +import android.view.MenuItem +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import org.oxycblt.auxio.databinding.ItemMenuOptionBinding +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.inflater + +/** + * Displays a list of [MenuItem]s as custom list items. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuOptionAdapter : + FlexibleListAdapter(MenuOptionViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + MenuOptionViewHolder.from(parent) + + override fun onBindViewHolder(holder: MenuOptionViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a list of menu options based on [MenuItem]. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuOptionViewHolder private constructor(private val binding: ItemMenuOptionBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + fun bind(item: MenuItem) { + binding.title.text = item.title + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup) = + MenuOptionViewHolder(ItemMenuOptionBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem == newItem + + override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem.title == newItem.title + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt index 9fc255ce1..90a5786f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt @@ -22,6 +22,7 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup +import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.core.view.isInvisible import androidx.core.view.updatePadding @@ -31,6 +32,7 @@ import com.google.android.material.divider.MaterialDivider import org.oxycblt.auxio.R import org.oxycblt.auxio.list.recycler.DialogRecyclerView.ViewHolder import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [RecyclerView] intended for use in Dialogs, adding features such as: @@ -74,6 +76,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr invalidateDividers() } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + // Update the RecyclerView's padding such that the bottom insets are applied + // while still preserving bottom padding. + updatePadding(bottom = insets.systemBarInsetsCompat.bottom) + return insets + } + override fun onScrolled(dx: Int, dy: Int) { super.onScrolled(dx, dy) // Scroll event occurred, need to update the dividers. diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index 4ecea1336..bca211f9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt @@ -35,7 +35,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -47,7 +47,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class MusicDirsDialog : - ViewBindingDialogFragment(), DirectoryAdapter.Listener { + ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null private var storageManager: StorageManager? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 3496ea059..d74b9ba53 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -29,19 +29,19 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logW /** - * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to - * split tags with multiple values. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the separator characters + * used to split tags with multiple values. * * @author Alexander Capehart (OxygenCobalt) * * TODO: Replace with unsplit names dialog */ @AndroidEntryPoint -class SeparatorsDialog : ViewBindingDialogFragment() { +class SeparatorsDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt index d22f9a5e9..00175dc2a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class AddToPlaylistDialog : - ViewBindingDialogFragment(), + ViewBindingMaterialDialogFragment(), ClickableListListener, NewPlaylistFooterAdapter.Listener { private val musicModel: MusicViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt index 15d347199..fbe599750 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt @@ -30,19 +30,19 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist]. + * A [ViewBindingMaterialDialogFragment] that asks the user to confirm the deletion of a [Playlist]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class DeletePlaylistDialog : ViewBindingDialogFragment() { +class DeletePlaylistDialog : ViewBindingMaterialDialogFragment() { private val pickerModel: PlaylistPickerViewModel by viewModels() private val musicModel: MusicViewModel by activityViewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt index d0c4a03cc..b21925781 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistDialog.kt @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class NewPlaylistDialog : ViewBindingDialogFragment() { +class NewPlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt index 20ed39bd5..024396525 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class RenamePlaylistDialog : ViewBindingDialogFragment() { +class RenamePlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt index e42e6ff61..1c7f025ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt @@ -33,19 +33,19 @@ import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Artist] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Artist] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt index 6811e3510..e47f87968 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt @@ -33,19 +33,19 @@ import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Genre] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromGenreDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments 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 dcd7db42e..6cc1cea54 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 @@ -28,16 +28,17 @@ import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. + * aa [ViewBindingMaterialDialogFragment] that allows user configuration of the current + * [ReplayGainPreAmp]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class PreAmpCustomizeDialog : ViewBindingDialogFragment() { +class PreAmpCustomizeDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var playbackSettings: PlaybackSettings override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt new file mode 100644 index 000000000..2072d62e7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 Auxio Project + * ViewBindingBottomSheetDialogFragment.kt is part of Auxio. + * + * 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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * [BottomSheetDialogFragment]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ViewBindingBottomSheetDialogFragment : + BottomSheetDialogFragment() { + private var _binding: VB? = null + + /** + * Configure the [AlertDialog.Builder] during [onCreateDialog]. + * + * @param builder The [AlertDialog.Builder] to configure. + * @see onCreateDialog + */ + protected open fun onConfigDialog(builder: AlertDialog.Builder) {} + + /** + * Inflate the [ViewBinding] during [onCreateView]. + * + * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with. + * @return A new [ViewBinding] instance. + * @see onCreateView + */ + protected abstract fun onCreateBinding(inflater: LayoutInflater): VB + + /** + * Configure the newly-inflated [ViewBinding] during [onViewCreated]. + * + * @param binding The [ViewBinding] to configure. + * @param savedInstanceState The previously saved state of the UI. + * @see onViewCreated + */ + protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} + + /** + * Free memory held by the [ViewBinding] during [onDestroyView] + * + * @param binding The [ViewBinding] to release. + * @see onDestroyView + */ + protected open fun onDestroyBinding(binding: VB) {} + + /** The [ViewBinding], or null if it has not been inflated yet. */ + protected val binding: VB? + get() = _binding + + /** + * Get the [ViewBinding] under the assumption that it has been inflated. + * + * @return The currently-inflated [ViewBinding]. + * @throws IllegalStateException if the [ViewBinding] is not inflated. + */ + 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, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = onCreateBinding(inflater).also { _binding = it }.root + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBindingCreated(requireBinding(), savedInstanceState) + logD("Fragment created") + } + + final override fun onDestroyView() { + super.onDestroyView() + onDestroyBinding(unlikelyToBeNull(_binding)) + // Clear binding + _binding = null + logD("Fragment destroyed") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt rename to app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt index 1615cbfd3..f12fe1b2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * ViewBindingDialogFragment.kt is part of Auxio. + * ViewBindingMaterialDialogFragment.kt is part of Auxio. * * 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 @@ -32,11 +32,12 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle. + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * material dialog. * * @author Alexander Capehart (OxygenCobalt) */ -abstract class ViewBindingDialogFragment : DialogFragment() { +abstract class ViewBindingMaterialDialogFragment : DialogFragment() { private var _binding: VB? = null /** 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 a09e0f0d5..c5ce7a5cc 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 @@ -29,18 +29,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that allows the user to configure the current [Accent]. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the current [Accent]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class AccentCustomizeDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) @Inject lateinit var uiSettings: UISettings diff --git a/app/src/main/res/layout/dialog_menu.xml b/app/src/main/res/layout/dialog_menu.xml new file mode 100644 index 000000000..72534ff15 --- /dev/null +++ b/app/src/main/res/layout/dialog_menu.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_menu_option.xml b/app/src/main/res/layout/item_menu_option.xml new file mode 100644 index 000000000..6064a7025 --- /dev/null +++ b/app/src/main/res/layout/item_menu_option.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml index b1694965c..9d0736663 100644 --- a/app/src/main/res/navigation/main.xml +++ b/app/src/main/res/navigation/main.xml @@ -30,6 +30,9 @@ + @@ -297,6 +300,12 @@ app:argType="org.oxycblt.auxio.music.Music$UID" /> + + @style/Theme.Auxio.Dialog @style/Widget.Auxio.Slider @style/Widget.Auxio.LinearProgressIndicator + @style/Widget.Auxio.BottomSheet + @style/Widget.Auxio.BottomSheet.Dialog @style/TextAppearance.Auxio.DisplayLarge @style/TextAppearance.Auxio.DisplayMedium diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 5ee16b401..105b4aab5 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -31,6 +31,28 @@ @dimen/size_corners_medium + + + + + + + + From 0d896c04e3c57da75a718d3d331809f501166c76 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 3 Jul 2023 16:07:54 -0600 Subject: [PATCH 005/127] list: clean up menu impl Clean up the some minor mistakes in the new menu dialog implementation. --- .../auxio/list/menu/MenuDialogFragment.kt | 5 +++ .../auxio/list/menu/MenuDialogFragmentImpl.kt | 41 ++++++++++++------- .../auxio/list/menu/MenuOptionAdapter.kt | 5 ++- app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/styles_ui.xml | 6 --- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt index 16ede2a8f..1f0317ae8 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt @@ -58,6 +58,8 @@ abstract class MenuDialogFragment : super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP --- + binding.menuName.isSelected = true + binding.menuInfo.isSelected = true binding.menuOptionRecycler.apply { adapter = menuAdapter itemAnimator = null @@ -75,6 +77,8 @@ abstract class MenuDialogFragment : override fun onDestroyBinding(binding: DialogMenuBinding) { super.onDestroyBinding(binding) + binding.menuName.isSelected = false + binding.menuInfo.isSelected = false binding.menuOptionRecycler.adapter = null } @@ -87,6 +91,7 @@ abstract class MenuDialogFragment : } final override fun onClick(item: MenuItem, viewHolder: RecyclerView.ViewHolder) { + findNavController().navigateUp() @Suppress("UNCHECKED_CAST") onClick(menuModel.currentMusic.value as T, item.itemId) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index 86ee874fe..9c1f8f7cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.databinding.DialogMenuBinding 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.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames @@ -36,13 +37,15 @@ class SongMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: SongMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.songUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.songUid override fun updateMusic(binding: DialogMenuBinding, music: Song) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_song) + binding.menuType.text = getString(R.string.lbl_song) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = music.artists.resolveNames(context) } @@ -55,13 +58,15 @@ class AlbumMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: AlbumMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.albumUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.albumUid override fun updateMusic(binding: DialogMenuBinding, music: Album) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(music.releaseType.stringRes) + binding.menuType.text = getString(music.releaseType.stringRes) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = music.artists.resolveNames(context) } @@ -74,13 +79,15 @@ class ArtistMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: ArtistMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.artistUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.artistUid override fun updateMusic(binding: DialogMenuBinding, music: Artist) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_artist) + binding.menuType.text = getString(R.string.lbl_artist) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = getString( @@ -101,13 +108,15 @@ class GenreMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: GenreMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.genreUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.genreUid override fun updateMusic(binding: DialogMenuBinding, music: Genre) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_genre) + binding.menuType.text = getString(R.string.lbl_genre) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = getString( @@ -124,13 +133,15 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { override val menuModel: MenuViewModel by viewModels() private val args: PlaylistMenuDialogFragmentArgs by navArgs() - override val menuRes = args.menuRes - override val uid = args.playlistUid + override val menuRes: Int + get() = args.menuRes + override val uid: Music.UID + get() = args.playlistUid override fun updateMusic(binding: DialogMenuBinding, music: Playlist) { val context = requireContext() binding.menuCover.bind(music) - binding.menuInfo.text = getString(R.string.lbl_genre) + binding.menuType.text = getString(R.string.lbl_playlist) binding.menuName.text = music.name.resolve(context) binding.menuInfo.text = context.getPlural(R.plurals.fmt_song_count, music.songs.size) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt index e5d7b2a85..e8d13279a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuOptionAdapter.kt @@ -39,7 +39,7 @@ class MenuOptionAdapter(private val listener: ClickableListListener) : MenuOptionViewHolder.from(parent) override fun onBindViewHolder(holder: MenuOptionViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(getItem(position), listener) } } @@ -50,7 +50,8 @@ class MenuOptionAdapter(private val listener: ClickableListListener) : */ class MenuOptionViewHolder private constructor(private val binding: ItemMenuOptionBinding) : DialogRecyclerView.ViewHolder(binding.root) { - fun bind(item: MenuItem) { + fun bind(item: MenuItem, listener: ClickableListListener) { + listener.bind(item, this) binding.title.text = item.title } diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 558ed6e40..064135105 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,7 +12,6 @@ 48dp 56dp - 92dp 128dp 192dp 256dp diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 18ecf8a2f..0f5675812 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -65,12 +65,6 @@ medium - - + + + + From f5c7f25cdf5b235e1899d959f28a950fddd1ce02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 17:42:05 -0600 Subject: [PATCH 066/127] home: add music loading error dialog Add a dialog that shows the stack trace of a music loading error. This is an MVP that is only available to music loading to resolve some immediate issues. Resolves #527. --- .../oxycblt/auxio/home/ErrorDetailsDialog.kt | 89 +++++++++++++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 11 ++- .../java/org/oxycblt/auxio/music/Indexing.kt | 2 +- .../oxycblt/auxio/music/MusicRepository.kt | 1 + .../oxycblt/auxio/settings/AboutFragment.kt | 84 ++--------------- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 67 ++++++++++++++ app/src/main/res/drawable/ic_copy_24.xml | 11 +++ .../fragment_playback_panel.xml | 4 +- .../main/res/layout/dialog_error_details.xml | 67 ++++++++++++++ app/src/main/res/layout/fragment_home.xml | 21 ++++- app/src/main/res/navigation/inner.xml | 13 +++ app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 8 ++ 13 files changed, 294 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt create mode 100644 app/src/main/res/drawable/ic_copy_24.xml create mode 100644 app/src/main/res/layout/dialog_error_details.xml diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt new file mode 100644 index 000000000..19ac97a34 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 Auxio Project + * ErrorDetailsDialog.kt is part of Auxio. + * + * 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.ClipData +import android.content.ClipboardManager +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.navigation.fragment.navArgs +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.openInBrowser +import org.oxycblt.auxio.util.showToast + +/** + * A dialog that shows a stack trace for a music loading error. + * + * TODO: Extend to other errors + * + * @author Alexander Capehart (OxygenCobalt) + */ +class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { + private val args: ErrorDetailsDialogArgs by navArgs() + private var clipboardManager: ClipboardManager? = null + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_error_info) + .setPositiveButton(R.string.lbl_report) { _, _ -> + requireContext().openInBrowser(LINK_ISSUES) + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogErrorDetailsBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class) + + // --- UI SETUP --- + binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n') + binding.errorCopy.setOnClickListener { copyStackTrace() } + } + + override fun onDestroyBinding(binding: DialogErrorDetailsBinding) { + super.onDestroyBinding(binding) + clipboardManager = null + } + + private fun copyStackTrace() { + requireNotNull(clipboardManager) { "Clipboard was unavailable" } + .setPrimaryClip( + ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString())) + // A copy notice is shown by the system from Android 13 onwards + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + requireContext().showToast(R.string.lbl_copied) + } + } + + private companion object { + /** The URL to the bug report issue form */ + const val LINK_ISSUES = + "https://github.com/OxygenCobalt/Auxio/issues/new" + + "?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml" + } +} 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 7228e10ec..c016c6602 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -330,11 +330,12 @@ class HomeFragment : } } - private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) { + private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { if (error == null) { logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE + binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -357,6 +358,7 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } + binding.homeIndexingError.visibility = View.INVISIBLE } is NoMusicException -> { logD("Showing no music error") @@ -367,6 +369,7 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } + binding.homeIndexingError.visibility = View.INVISIBLE } else -> { logD("Showing generic error") @@ -377,6 +380,12 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } + binding.homeIndexingError.apply { + visibility = View.VISIBLE + setOnClickListener { + findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) + } + } } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt index a185d5b2f..d4e582660 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -47,7 +47,7 @@ sealed interface IndexingState { * @param error If music loading has failed, the error that occurred will be here. Otherwise, it * will be null. */ - data class Completed(val error: Throwable?) : IndexingState + data class Completed(val error: Exception?) : IndexingState } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 6e45c5ae9..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -371,6 +371,7 @@ constructor( // parallel. logD("Starting MediaStore query") emitIndexingProgress(IndexingProgress.Indeterminate) + val mediaStoreQueryJob = worker.scope.async { val query = 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 cd6217a9f..3c3258ab9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -18,13 +18,8 @@ package org.oxycblt.auxio.settings -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.view.LayoutInflater -import androidx.core.net.toUri import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -37,8 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.openInBrowser import org.oxycblt.auxio.util.systemBarInsetsCompat /** @@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment() { } binding.aboutVersion.text = BuildConfig.VERSION_NAME - binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) } - binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) } - binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } - binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) } + binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) } + binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) } + binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } + binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) } // VIEWMODEL SETUP collectImmediately(musicModel.statistics, ::updateStatistics) @@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment() { (statistics?.durationMs ?: 0).formatDurationMs(false)) } - /** - * Open the given URI in a web browser. - * - * @param uri The URL to open. - */ - private fun openLinkInBrowser(uri: String) { - logD("Opening $uri") - val context = requireContext() - val browserIntent = - Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11 seems to now handle the app chooser situations on its own now - // [along with adding a new permission that breaks the old manual code], so - // we just do a typical activity launch. - logD("Using API 30+ chooser") - try { - context.startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // No app installed to open the link - context.showToast(R.string.err_no_app) - } - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - // On older versions of android, opening links from an ACTION_VIEW intent might - // not work in all cases, especially when no default app was set. If that is the - // case, we will try to manually handle these cases before we try to launch the - // browser. - logD("Resolving browser activity for chooser") - val pkgName = - context.packageManager - .resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) - ?.run { activityInfo.packageName } - - if (pkgName != null) { - if (pkgName == "android") { - // No default browser [Must open app chooser, may not be supported] - logD("No default browser found") - openAppChooser(browserIntent) - } else logD("Opening browser intent") - 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) - } - } - } - - /** - * Open an app chooser for a given [Intent]. - * - * @param intent The [Intent] to show an app chooser for. - */ - private fun openAppChooser(intent: Intent) { - logD("Opening app chooser for ${intent.action}") - val chooserIntent = - Intent(Intent.ACTION_CHOOSER) - .putExtra(Intent.EXTRA_INTENT, intent) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(chooserIntent) - } - private companion object { /** The URL to the source code. */ const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio" diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index e58a34c93..1662c47c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -18,7 +18,10 @@ package org.oxycblt.auxio.util +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Build @@ -33,6 +36,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.net.toUri import androidx.core.view.children import androidx.navigation.NavController import androidx.navigation.NavDirections @@ -41,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.google.android.material.appbar.MaterialToolbar import java.lang.IllegalArgumentException +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -322,3 +327,65 @@ fun Context.share(songs: Collection) { builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser() } + +/** + * Open the given URI in a web browser. + * + * @param uri The URL to open. + */ +fun Context.openInBrowser(uri: String) { + fun openAppChooser(intent: Intent) { + logD("Opening app chooser for ${intent.action}") + val chooserIntent = + Intent(Intent.ACTION_CHOOSER) + .putExtra(Intent.EXTRA_INTENT, intent) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(chooserIntent) + } + + logD("Opening $uri") + val browserIntent = + Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11 seems to now handle the app chooser situations on its own now + // [along with adding a new permission that breaks the old manual code], so + // we just do a typical activity launch. + logD("Using API 30+ chooser") + try { + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // No app installed to open the link + showToast(R.string.err_no_app) + } + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + // On older versions of android, opening links from an ACTION_VIEW intent might + // not work in all cases, especially when no default app was set. If that is the + // case, we will try to manually handle these cases before we try to launch the + // browser. + logD("Resolving browser activity for chooser") + val pkgName = + packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)?.run { + activityInfo.packageName + } + + if (pkgName != null) { + if (pkgName == "android") { + // No default browser [Must open app chooser, may not be supported] + logD("No default browser found") + openAppChooser(browserIntent) + } else logD("Opening browser intent") + 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 + showToast(R.string.err_no_app) + } + } +} diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 000000000..65bb96df5 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index c673dd8ca..b46562fa2 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -69,8 +69,8 @@ android:id="@+id/playback_seek_bar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_small" - android:layout_marginEnd="@dimen/spacing_small" + android:layout_marginStart="@dimen/spacing_tiny" + android:layout_marginEnd="@dimen/spacing_tiny" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" diff --git a/app/src/main/res/layout/dialog_error_details.xml b/app/src/main/res/layout/dialog_error_details.xml new file mode 100644 index 000000000..729c17d0b --- /dev/null +++ b/app/src/main/res/layout/dialog_error_details.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 049256481..8eeb42c13 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -108,16 +108,33 @@ + + diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index d665a90d3..b1f834f95 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -81,8 +81,21 @@ + + + + + 48dp 56dp 64dp + 64dp 72dp 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd9f2c2e7..852e9eeae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Monitoring music library Retry + + More Grant @@ -168,6 +170,12 @@ Selection + Error information + + Copied + + Report + From e912120f9fecb29f3018141719b4c23be465d8e7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 19:54:31 -0600 Subject: [PATCH 067/127] all: general cleanup --- CHANGELOG.md | 1 + .../main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt | 4 ++-- .../org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e66ee13..81d1a81e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Item and sort menus have been refreshed with a cleaner look - Added ability to sort playlists - Added option to play song by itself in library/item details +- Added error details when music loading fails #### What's Improved - Made "Add to Playlist" action more prominent in selection toolbar diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt index 19ac97a34..e88ed1175 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -35,9 +35,9 @@ import org.oxycblt.auxio.util.showToast /** * A dialog that shows a stack trace for a music loading error. * - * TODO: Extend to other errors - * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Extend to other errors */ class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { private val args: ErrorDetailsDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt index 1c3e9f340..58fe95c14 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt @@ -24,7 +24,10 @@ import androidx.navigation.NavDestination /** * A [NavController.OnDestinationChangedListener] that will call [callback] when moving between - * fragments only (not between dialogs or anything similar) + * fragments only (not between dialogs or anything similar). + * + * Note: This only works because of special naming used in Auxio's navigation graphs. Keep this in + * mind when porting to other projects. * * @author Alexander Capehart (OxygenCobalt) */ @@ -74,6 +77,5 @@ class DialogAwareNavigationListener(private val callback: () -> Unit) : } } - /** This relies on special label naming used in-app. */ private fun NavDestination.isDialog() = label?.endsWith("dialog") == true } From e32fc6b60985b3c40d7d443f6b0d28daf708251b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 15 Aug 2023 20:48:17 -0600 Subject: [PATCH 068/127] home: fix misaligned grant/retry button Caused by naive visibility logic when I added the "More" button prior. Resolves #544. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 17 +++--- .../oxycblt/auxio/music/MusicRepository.kt | 1 + app/src/main/res/layout/fragment_home.xml | 59 ++++++++++--------- 3 files changed, 40 insertions(+), 37 deletions(-) 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 c016c6602..01558611d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -335,7 +335,6 @@ class HomeFragment : logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE - binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -343,13 +342,13 @@ class HomeFragment : val context = requireContext() binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.VISIBLE when (error) { is NoAudioPermissionException -> { logD("Showing permission prompt") binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) // Configure the action to act as a permission launcher. - binding.homeIndexingAction.apply { - visibility = View.VISIBLE + binding.homeIndexingTry.apply { text = context.getString(R.string.lbl_grant) setOnClickListener { requireNotNull(storagePermissionLauncher) { @@ -358,29 +357,29 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } is NoMusicException -> { logD("Showing no music error") binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } else -> { logD("Showing generic error") binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } - binding.homeIndexingError.apply { + binding.homeIndexingMore.apply { visibility = View.VISIBLE setOnClickListener { findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) @@ -394,7 +393,7 @@ class HomeFragment : // Remove all content except for the progress indicator. binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE - binding.homeIndexingAction.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.INVISIBLE when (progress) { is IndexingProgress.Indeterminate -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..e3f2a0abb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8eeb42c13..f1b5c8c80 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -70,8 +70,8 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:layout_margin="@dimen/spacing_medium" - android:fitsSystemWindows="true" - android:visibility="invisible"> + android:visibility="invisible" + android:fitsSystemWindows="true"> @@ -103,37 +103,40 @@ android:layout_marginEnd="@dimen/spacing_medium" android:indeterminate="true" app:indeterminateAnimationType="disjoint" - app:layout_constraintBottom_toBottomOf="@+id/home_indexing_action" - app:layout_constraintTop_toTopOf="@+id/home_indexing_action" /> + app:layout_constraintBottom_toBottomOf="@+id/home_indexing_actions" + app:layout_constraintTop_toTopOf="@+id/home_indexing_actions" /> - - - + tools:layout_editor_absoluteX="16dp"> + + + + + + From b43e4695c080be09e71c9a70c36a21b057e9df5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 15:09:45 -0600 Subject: [PATCH 069/127] build: update deps fragment: 1.6.0 -> 1.6.1 preferences: 1.2.0 -> 1.2.1 room: 2.6.0-alpha02 -> 2.6.0-alpha03 material: 1.10.0-alpha05 -> 1.10.0-alpha06 media: 1.1.0 -> 1.1.1 --- app/build.gradle | 12 +++++------- build.gradle | 2 +- media | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f088245c7..efac4f42e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.1.4" + versionName "3.1.0" versionCode 34 minSdk 24 @@ -88,7 +88,7 @@ dependencies { implementation "androidx.core:core-ktx:1.10.1" implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.activity:activity-ktx:1.7.2" - implementation "androidx.fragment:fragment-ktx:1.6.0" + implementation "androidx.fragment:fragment-ktx:1.6.1" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -114,13 +114,11 @@ dependencies { implementation "androidx.media:media:1.6.0" // Preferences - implementation "androidx.preference:preference-ktx:1.2.0" + implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-alpha02' + def room_version = '2.6.0-alpha03' implementation "androidx.room:room-runtime:$room_version" - // I have no clue why, but using KSP breaks the playlist database definition. - //noinspection KaptUsageInsteadOfKsp ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -136,7 +134,7 @@ dependencies { // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.10.0-alpha05" + implementation "com.google.android.material:material:1.10.0-alpha06" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" diff --git a/build.gradle b/build.gradle index 75fa2ca64..e5bc1dc66 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.6.0" + navigation_version = "2.7.0" hilt_version = '2.47' } diff --git a/media b/media index 316763308..40c3e5c68 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 316763308d3143c75270103c85cf2d984bfa34a0 +Subproject commit 40c3e5c68cbdf8758037aa40b4071cca8a53ee89 From 70a5bab9214c9592979504fe006d225982872ce2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:12:56 -0600 Subject: [PATCH 070/127] ui: vendor bottom sheet dialog w/fixes Vendor BottomSheetDialog(Fragment) with the inset fix that prior used reflection. Apparently said reflection breaks down and crashes the release build somehow. So now I just have to hastily patch BackportBottomSheetBehavior and vendor another 1000 lines of MDC code. Really considering making a PHP sadness-like blog solely for android at this point. --- .../BackportBottomSheetBehavior.java | 32 +- .../BackportBottomSheetDialog.java | 551 ++++++++++++++++++ .../BackportBottomSheetDialogFragment.java | 120 ++++ .../ViewBindingBottomSheetDialogFragment.kt | 66 +-- .../res/layout/design_bottom_sheet_dialog.xml | 51 ++ app/src/main/res/values/styles_ui.xml | 8 + 6 files changed, 753 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java create mode 100644 app/src/main/res/layout/design_bottom_sheet_dialog.xml diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index 214f6ac62..f9e8edb42 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1737,16 +1737,10 @@ private void setWindowInsetsListener(@NonNull View child) { final boolean shouldHandleGestureInsets = VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; - // If were not handling insets at all, don't apply the listener. - if (!paddingBottomSystemWindowInsets - && !paddingLeftSystemWindowInsets - && !paddingRightSystemWindowInsets - && !marginLeftSystemWindowInsets - && !marginRightSystemWindowInsets - && !marginTopSystemWindowInsets - && !shouldHandleGestureInsets) { - return; - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + ViewUtils.doOnApplyWindowInsets( child, new ViewUtils.OnApplyWindowInsetsListener() { @@ -1759,6 +1753,12 @@ public WindowInsetsCompat onApplyWindowInsets( insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + // Intentionally uses getSystemWindowInsetBottom to apply padding properly when + // adjustResize is used as the windowSoftInputMode. + insetBottom = insets.getSystemWindowInsetBottom(); boolean isRtl = ViewUtils.isLayoutRtl(view); @@ -1767,9 +1767,6 @@ public WindowInsetsCompat onApplyWindowInsets( int rightPadding = view.getPaddingRight(); if (paddingBottomSystemWindowInsets) { - // Intentionally uses getSystemWindowInsetBottom to apply padding properly when - // adjustResize is used as the windowSoftInputMode. - insetBottom = insets.getSystemWindowInsetBottom(); bottomPadding = initialPadding.bottom + insetBottom; } @@ -1810,11 +1807,10 @@ public WindowInsetsCompat onApplyWindowInsets( gestureInsetBottom = mandatoryGestureInsets.bottom; } - // Don't update the peek height to be above the navigation bar or gestures if these - // flags are off. It means the client is already handling it. - if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { - updatePeekHeight(/* animate= */ false); - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + updatePeekHeight(/* animate= */ false); return insets; } }); diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java new file mode 100644 index 000000000..af5cc64bf --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import com.google.android.material.R; + +import static com.google.android.material.color.MaterialColors.isColorLight; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialog; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager.LayoutParams; +import android.widget.FrameLayout; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.material.internal.EdgeToEdgeUtils; +import com.google.android.material.motion.MaterialBackOrchestrator; +import com.google.android.material.shape.MaterialShapeDrawable; + +import org.checkerframework.common.subtyping.qual.Bottom; + +/** + * Base class for {@link android.app.Dialog}s styled as a bottom sheet. + * + *

Edge to edge window flags are automatically applied if the {@link + * android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is + * true. These can be set in the theme that is passed to the constructor, or will be taken from the + * theme of the context (ie. your application or activity theme). + * + *

In edge to edge mode, padding will be added automatically to the top when sliding under the + * status bar. Padding can be applied automatically to the left, right, or bottom if any of + * `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or + * `paddingRightSystemWindowInsets` are set to true in the style. + * + * MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior + */ +public class BackportBottomSheetDialog extends AppCompatDialog { + + private BackportBottomSheetBehavior behavior; + + private FrameLayout container; + private CoordinatorLayout coordinator; + private FrameLayout bottomSheet; + + boolean dismissWithAnimation; + + boolean cancelable = true; + private boolean canceledOnTouchOutside = true; + private boolean canceledOnTouchOutsideSet; + private EdgeToEdgeCallback edgeToEdgeCallback; + private boolean edgeToEdgeEnabled; + @Nullable private MaterialBackOrchestrator backOrchestrator; + + public BackportBottomSheetDialog(@NonNull Context context) { + this(context, 0); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) { + super(context, getThemeResId(context, theme)); + // We hide the title bar for any style configuration. Otherwise, there will be a gap + // above the bottom sheet when it is expanded. + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + protected BackportBottomSheetDialog( + @NonNull Context context, boolean cancelable, OnCancelListener cancelListener) { + super(context, cancelable, cancelListener); + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + this.cancelable = cancelable; + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + @Override + public void setContentView(@LayoutRes int layoutResId) { + super.setContentView(wrapInBottomSheet(layoutResId, null, null)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // The status bar should always be transparent because of the window animation. + window.setStatusBarColor(0); + + window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + if (VERSION.SDK_INT < VERSION_CODES.M) { + // It can be transparent for API 23 and above because we will handle switching the status + // bar icons to light or dark as appropriate. For API 21 and API 22 we just set the + // translucent status bar. + window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + } + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + @Override + public void setContentView(View view) { + super.setContentView(wrapInBottomSheet(0, view, null)); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + super.setContentView(wrapInBottomSheet(0, view, params)); + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + if (this.cancelable != cancelable) { + this.cancelable = cancelable; + if (behavior != null) { + behavior.setHideable(cancelable); + } + if (getWindow() != null) { + updateListeningForBackCallbacks(); + } + } + } + + @Override + protected void onStart() { + super.onStart(); + if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED); + } + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // If the navigation bar is transparent at all the BottomSheet should be edge to edge. + boolean drawEdgeToEdge = + edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255; + if (container != null) { + container.setFitsSystemWindows(!drawEdgeToEdge); + } + if (coordinator != null) { + coordinator.setFitsSystemWindows(!drawEdgeToEdge); + } + WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge); + } + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(window); + } + } + + updateListeningForBackCallbacks(); + } + + @Override + public void onDetachedFromWindow() { + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(null); + } + + if (backOrchestrator != null) { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + /** + * This function can be called from a few different use cases, including Swiping the dialog down + * or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc... + * + *

The default animation to dismiss this dialog is a fade-out transition through a + * windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the + * BottomSheet animation instead. + * + *

If this function is called from a swipe down interaction, or dismissWithAnimation is false, + * then keep the default behavior. + * + *

Else, since this is a terminal event which will finish this dialog, we override the attached + * {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link + * BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before + * canceling this dialog. + */ + @Override + public void cancel() { + BackportBottomSheetBehavior behavior = getBehavior(); + + if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + super.cancel(); + } else { + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + @Override + public void setCanceledOnTouchOutside(boolean cancel) { + super.setCanceledOnTouchOutside(cancel); + if (cancel && !cancelable) { + cancelable = true; + } + canceledOnTouchOutside = cancel; + canceledOnTouchOutsideSet = true; + } + + @NonNull + public BackportBottomSheetBehavior getBehavior() { + if (behavior == null) { + // The content hasn't been set, so the behavior doesn't exist yet. Let's create it. + ensureContainerAndBehavior(); + } + return behavior; + } + + /** + * Set to perform the swipe down animation when dismissing instead of the window animation for the + * dialog. + * + * @param dismissWithAnimation True if swipe down animation should be used when dismissing. + */ + public void setDismissWithAnimation(boolean dismissWithAnimation) { + this.dismissWithAnimation = dismissWithAnimation; + } + + /** + * Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than + * the window animation for the dialog. + */ + public boolean getDismissWithAnimation() { + return dismissWithAnimation; + } + + /** Returns if edge to edge behavior is enabled for this dialog. */ + public boolean getEdgeToEdgeEnabled() { + return edgeToEdgeEnabled; + } + + /** Creates the container layout which must exist to find the behavior */ + private FrameLayout ensureContainerAndBehavior() { + if (container == null) { + container = + (FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null); + + coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet); + + // MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior + behavior = BackportBottomSheetBehavior.from(bottomSheet); + behavior.addBottomSheetCallback(bottomSheetCallback); + behavior.setHideable(cancelable); + + backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet); + } + return container; + } + + private View wrapInBottomSheet( + int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) { + ensureContainerAndBehavior(); + CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + if (layoutResId != 0 && view == null) { + view = getLayoutInflater().inflate(layoutResId, coordinator, false); + } + + if (edgeToEdgeEnabled) { + ViewCompat.setOnApplyWindowInsetsListener( + bottomSheet, + new OnApplyWindowInsetsListener() { + @Override + public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { + if (edgeToEdgeCallback != null) { + behavior.removeBottomSheetCallback(edgeToEdgeCallback); + } + + if (insets != null) { + edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets); + edgeToEdgeCallback.setWindow(getWindow()); + behavior.addBottomSheetCallback(edgeToEdgeCallback); + } + + return insets; + } + }); + } + + bottomSheet.removeAllViews(); + if (params == null) { + bottomSheet.addView(view); + } else { + bottomSheet.addView(view, params); + } + // We treat the CoordinatorLayout as outside the dialog though it is technically inside + coordinator + .findViewById(R.id.touch_outside) + .setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) { + cancel(); + } + } + }); + // Handle accessibility events + ViewCompat.setAccessibilityDelegate( + bottomSheet, + new AccessibilityDelegateCompat() { + @Override + public void onInitializeAccessibilityNodeInfo( + View host, @NonNull AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + if (cancelable) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS); + info.setDismissable(true); + } else { + info.setDismissable(false); + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) { + cancel(); + return true; + } + return super.performAccessibilityAction(host, action, args); + } + }); + bottomSheet.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View view, MotionEvent event) { + // Consume the event and prevent it from falling through + return true; + } + }); + return container; + } + + private void updateListeningForBackCallbacks() { + if (backOrchestrator == null) { + return; + } + if (cancelable) { + backOrchestrator.startListeningForBackCallbacks(); + } else { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + boolean shouldWindowCloseOnTouchOutside() { + if (!canceledOnTouchOutsideSet) { + TypedArray a = + getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside}); + canceledOnTouchOutside = a.getBoolean(0, true); + a.recycle(); + canceledOnTouchOutsideSet = true; + } + return canceledOnTouchOutside; + } + + private static int getThemeResId(@NonNull Context context, int themeId) { + if (themeId == 0) { + // If the provided theme is 0, then retrieve the dialogTheme from our theme + TypedValue outValue = new TypedValue(); + if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) { + themeId = outValue.resourceId; + } else { + // bottomSheetDialogTheme is not provided; we default to our light theme + themeId = R.style.Theme_Design_Light_BottomSheetDialog; + } + } + return themeId; + } + + void removeDefaultCallback() { + behavior.removeBottomSheetCallback(bottomSheetCallback); + } + + @NonNull + private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback = + new BackportBottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged( + @NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + cancel(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + }; + + private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Nullable private final Boolean lightBottomSheet; + @NonNull private final WindowInsetsCompat insetsCompat; + + @Nullable private Window window; + private boolean lightStatusBar; + + private EdgeToEdgeCallback( + @NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) { + this.insetsCompat = insetsCompat; + + // Try to find the background color to automatically change the status bar icons so they will + // still be visible when the bottomsheet slides underneath the status bar. + ColorStateList backgroundTint; + MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable(); + if (msd != null) { + backgroundTint = msd.getFillColor(); + } else { + backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet); + } + + if (backgroundTint != null) { + // First check for a tint + lightBottomSheet = isColorLight(backgroundTint.getDefaultColor()); + } else if (bottomSheet.getBackground() instanceof ColorDrawable) { + // Then check for the background color + lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor()); + } else { + // Otherwise don't change the status bar color + lightBottomSheet = null; + } + } + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + setPaddingForPosition(bottomSheet); + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + setPaddingForPosition(bottomSheet); + } + + @Override + void onLayout(@NonNull View bottomSheet) { + setPaddingForPosition(bottomSheet); + } + + void setWindow(@Nullable Window window) { + if (this.window == window) { + return; + } + this.window = window; + if (window != null) { + WindowInsetsControllerCompat insetsController = + WindowCompat.getInsetsController(window, window.getDecorView()); + lightStatusBar = insetsController.isAppearanceLightStatusBars(); + } + } + + private void setPaddingForPosition(View bottomSheet) { + if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) { + // If the bottomsheet is light, we should set light status bar so the icons are visible + // since the bottomsheet is now under the status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar( + window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet); + } + // Smooth transition into status bar when drawing edge to edge. + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + (insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()), + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } else if (bottomSheet.getTop() != 0) { + // Reset the status bar icons to the original color because the bottomsheet is not under the + // status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar); + } + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + 0, + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } + } + } + + /** + * @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead + */ + @Deprecated + public static void setLightStatusBar(@NonNull View view, boolean isLight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int flags = view.getSystemUiVisibility(); + if (isLight) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + view.setSystemUiVisibility(flags); + } + } +} diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java new file mode 100644 index 000000000..eead66daa --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialogFragment; +import android.view.View; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows + * a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog. + */ +public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment { + + /** + * Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the + * BottomSheet is hidden and onStateChanged() is called. + */ + private boolean waitingForDismissAllowingStateLoss; + + public BackportBottomSheetDialogFragment() {} + + @SuppressLint("ValidFragment") + public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) { + super(contentLayoutId); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + return new BackportBottomSheetDialog(getContext(), getTheme()); + } + + @Override + public void dismiss() { + if (!tryDismissWithAnimation(false)) { + super.dismiss(); + } + } + + @Override + public void dismissAllowingStateLoss() { + if (!tryDismissWithAnimation(true)) { + super.dismissAllowingStateLoss(); + } + } + + /** + * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, + * false otherwise. + */ + private boolean tryDismissWithAnimation(boolean allowingStateLoss) { + Dialog baseDialog = getDialog(); + if (baseDialog instanceof BackportBottomSheetDialog) { + BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog; + BackportBottomSheetBehavior behavior = dialog.getBehavior(); + if (behavior.isHideable() && dialog.getDismissWithAnimation()) { + dismissWithAnimation(behavior, allowingStateLoss); + return true; + } + } + + return false; + } + + private void dismissWithAnimation( + @NonNull BackportBottomSheetBehavior behavior, boolean allowingStateLoss) { + waitingForDismissAllowingStateLoss = allowingStateLoss; + + if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } else { + if (getDialog() instanceof BackportBottomSheetDialog) { + ((BackportBottomSheetDialog) getDialog()).removeDefaultCallback(); + } + behavior.addBottomSheetCallback(new BottomSheetDismissCallback()); + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + private void dismissAfterAnimation() { + if (waitingForDismissAllowingStateLoss) { + super.dismissAllowingStateLoss(); + } else { + super.dismiss(); + } + } + + private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt index e3087d42f..8abffb38f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -20,25 +20,18 @@ package org.oxycblt.auxio.ui import android.content.Context import android.os.Bundle -import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.annotation.StyleRes import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import java.lang.reflect.Field -import java.lang.reflect.Method -import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.getDimenPixels -import org.oxycblt.auxio.util.lazyReflectedField -import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,10 +41,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingBottomSheetDialogFragment : - BottomSheetDialogFragment() { + BackportBottomSheetDialogFragment() { private var _binding: VB? = null - override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheetDialog = + override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog = TweakedBottomSheetDialog(requireContext(), theme) /** @@ -100,10 +93,7 @@ abstract class ViewBindingBottomSheetDialogFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val root = onCreateBinding(inflater).also { _binding = it }.root - return EdgeToEdgeFixWrapperLayout(root.context).apply { addView(root) } - } + ) = onCreateBinding(inflater).also { _binding = it }.root final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -121,7 +111,8 @@ abstract class ViewBindingBottomSheetDialogFragment : private inner class TweakedBottomSheetDialog @JvmOverloads - constructor(context: Context, @StyleRes theme: Int = 0) : BottomSheetDialog(context, theme) { + constructor(context: Context, @StyleRes theme: Int = 0) : + BackportBottomSheetDialog(context, theme) { private var avoidUnusableCollapsedState = false override fun onCreate(savedInstanceState: Bundle?) { @@ -141,47 +132,8 @@ abstract class ViewBindingBottomSheetDialogFragment : super.onStart() if (avoidUnusableCollapsedState) { // skipCollapsed isn't enough, also need to immediately snap to expanded state. - behavior.state = BottomSheetBehavior.STATE_EXPANDED - } - } - } - - private class EdgeToEdgeFixWrapperLayout - @JvmOverloads - constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - // BottomSheetBehavior's normal window inset behavior is awful. It doesn't - // follow true edge-to-edge and instead just blindly pads the bottom part of - // the view, causing visual clipping. We can turn it off, but that throws - // of the peek height calculation and results in a collapsed state that only - // expands a few pixels (specifically the size of the bottom inset) into an - // expanded state. So, ideally we would just vendor and eliminate the padding - // changes entirely, but due to layout dependencies that requires vendoring - // both BottomSheetDialog and BottomSheetDialogFragment, which I generally - // don't want to do. Instead, we deliberately clobber the window insets listener - // of our bottom sheet and only re-implement the update of the cached inset - // variables and the peek height update. This way, the peek height calculation - // remains consistent and the top inset animation continues to work correctly - // without the other absurd edge-to-edge behaviors. - // TODO: Do a fix for this upstream - (parent as View).setOnApplyWindowInsetsListener { v, insets -> - val bsb = v.coordinatorLayoutBehavior as BottomSheetBehavior - BSB_INSET_TOP_FIELD.set(bsb, insets.systemBarInsetsCompat.top) - BSB_INSET_BOTTOM_FIELD.set(bsb, insets.systemBarInsetsCompat.bottom) - BSB_UPDATE_PEEK_HEIGHT_METHOD.invoke(bsb, false) - insets + behavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } - - private companion object { - val BSB_INSET_TOP_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetTop") - val BSB_INSET_BOTTOM_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetBottom") - val BSB_UPDATE_PEEK_HEIGHT_METHOD: Method by - lazyReflectedMethod(BottomSheetBehavior::class, "updatePeekHeight", Boolean::class) - } } } diff --git a/app/src/main/res/layout/design_bottom_sheet_dialog.xml b/app/src/main/res/layout/design_bottom_sheet_dialog.xml new file mode 100644 index 000000000..bb70eccbb --- /dev/null +++ b/app/src/main/res/layout/design_bottom_sheet_dialog.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 18e87da49..5106e12b9 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -32,11 +32,19 @@ From 449ec7cecd813b5b4de5835c8c8c14b2f6fd0987 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:32:26 -0600 Subject: [PATCH 071/127] ui: fix gap in landscape bottom sheet dialog Apparently a second-order effect of the prior fix since the insetTop value would now shift the dialog downwards unneccessarily. --- .../material/bottomsheet/BackportBottomSheetBehavior.java | 5 ++++- .../material/bottomsheet/BackportBottomSheetDialog.java | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index f9e8edb42..577036ec4 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1752,7 +1752,10 @@ public WindowInsetsCompat onApplyWindowInsets( Insets mandatoryGestureInsets = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); - insetTop = systemBarInsets.top; + // MODIFICATION: Fix second order change of edge-to-edge fix where dialogs will not + // use the nice-looking inset animation and instead blindly shift themselves downwards. + // insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves // don't need peek height adjustments (Despite the fact that they still likely padding // the view, just without clipping anything) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java index af5cc64bf..060fe04d2 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -53,8 +53,6 @@ import com.google.android.material.motion.MaterialBackOrchestrator; import com.google.android.material.shape.MaterialShapeDrawable; -import org.checkerframework.common.subtyping.qual.Bottom; - /** * Base class for {@link android.app.Dialog}s styled as a bottom sheet. * From f400aa513c42999c240c015bcdd7bce4b84536a1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:20:21 -0600 Subject: [PATCH 072/127] ui: mitigate navigation desync bug This thing reared it's ugly head again during 3.2.0 testing. I think I've found a terrible but probably functional workaround for it. Start using it. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..6747acb65 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,6 +148,14 @@ class MainFragment : } } + // Workaround for a bug where fast navigation ends up desynchronizing the current + // destination in the main navigation graph. + findNavController().apply { + findDestination(R.id.main_fragment)?.let { + currentBackStackEntry?.destination = it + } + } + // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) From 80268498560aded7cc8a3d053d00e32ee2f58029 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:41:19 -0600 Subject: [PATCH 073/127] all: cleanup --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 4 +--- app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 1 - app/src/main/res/values-ar-rIQ/strings.xml | 2 -- app/src/main/res/values-ar/strings.xml | 2 -- app/src/main/res/values-be/strings.xml | 2 -- app/src/main/res/values-cs/strings.xml | 2 -- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-el/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 2 -- app/src/main/res/values-fi/strings.xml | 2 -- app/src/main/res/values-fr/strings.xml | 2 -- app/src/main/res/values-gl/strings.xml | 2 -- app/src/main/res/values-hi/strings.xml | 2 -- app/src/main/res/values-hr/strings.xml | 2 -- app/src/main/res/values-hu/strings.xml | 2 -- app/src/main/res/values-in/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-iw/strings.xml | 2 -- app/src/main/res/values-ja/strings.xml | 2 -- app/src/main/res/values-ko/strings.xml | 2 -- app/src/main/res/values-lt/strings.xml | 2 -- app/src/main/res/values-ml/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 2 -- app/src/main/res/values-nl/strings.xml | 2 -- app/src/main/res/values-pa/strings.xml | 2 -- app/src/main/res/values-pl/strings.xml | 2 -- app/src/main/res/values-pt-rBR/strings.xml | 2 -- app/src/main/res/values-pt-rPT/strings.xml | 2 -- app/src/main/res/values-ro/strings.xml | 2 -- app/src/main/res/values-ru/strings.xml | 2 -- app/src/main/res/values-sv/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 2 -- app/src/main/res/values-uk/strings.xml | 2 -- app/src/main/res/values-zh-rCN/strings.xml | 2 -- app/src/main/res/values/strings.xml | 2 -- 35 files changed, 1 insertion(+), 69 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 6747acb65..2163721de 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -151,9 +151,7 @@ class MainFragment : // Workaround for a bug where fast navigation ends up desynchronizing the current // destination in the main navigation graph. findNavController().apply { - findDestination(R.id.main_fragment)?.let { - currentBackStackEntry?.destination = it - } + findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } } // --- VIEWMODEL SETUP --- diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index e3f2a0abb..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 9dc9abb35..97794174f 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -145,8 +145,6 @@ الحجم المسار إحصائيات المكتبة - تشغي الاغاني المحددة بترتيب عشوائي - تشغيل الموسيقى المحددة معدل البت اسم الملف تجميع مباشر diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fcbf1ee96..ca6f6fafd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -14,7 +14,6 @@ حذف قائمة التشغيل؟ بحث تصفية - تشغيل المختارة تشغيل التالي إضافة للطابور إضافة لقائمة التشغيل @@ -28,7 +27,6 @@ قائمة تشغيل جديدة إعادة تسمية قائمة التشغيل تعديل - خلط المختارة طابور خلط اذهب للفنان diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 196cd5357..106d70d59 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -72,7 +72,6 @@ Зараз іграе Гуляць Ператасаваць - Выбрана перамешванне Памер Ператасаваць Адмяніць @@ -81,7 +80,6 @@ Гуляць далей Дадаць у чаргу Эквалайзер - Гуляць выбрана Чарга Перайсці да альбома Перайсці да выканаўцы diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 35c984fed..9e55b50e3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -260,9 +260,7 @@ Nepodařilo se vymazat stav Znovu najít hudbu Vymazat mezipaměť značek a znovu úplně znovu načíst hudební knihovnu (pomalejší, ale úplnější) - Přehrát vybrané Vybráno %d - Náhodně přehrát vybrané Přehrát z žánru Wiki %1$s, %2$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eec9aa65e..d30ef1144 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -251,8 +251,6 @@ Zustand konnte nicht gespeichert werden Music neu scannen Tag-Cache leeren und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger) - Ausgewählte abspielen - Ausgewählte zufällig abspielen %d ausgewählt Vom Genre abspielen Wiki diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 0e7da1415..b5730db8e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -135,8 +135,6 @@ Σύνθεση ζωντανών κομματιών Σύνθεση ρεμίξ Ισοσταθμιστής - Αναπαραγωγή επιλεγμένου - Τυχαία αναπαραγωγή επιλεγμένων Ενιαία κυκλοφορία Σινγκλ \ 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 70b8b49fc..0712780dd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -255,9 +255,7 @@ No se puede borrar el estado Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo) Volver a escanear la música - Nodo aleatorio seleccionado %d seleccionado - Reproducir los seleccionados Reproducir desde el género Wiki %1$s, %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 1f222e0a7..55677cbf1 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -37,7 +37,6 @@ Nyt toistetaan Taajuuskorjain Toista - Toisto valittu Sekoita Jono Lisää jonoon @@ -219,7 +218,6 @@ ReplayGain Suosi albumia ReplayGain-strategia - Sekoitus valittu Automaattinen uudelleenlataus Automaattitoisto kuulokkeilla Aloita aina toisto, kun kuulokkeet yhdistetään (ei välttämättä toimi kaikilla laitteilla) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 822484268..b063a1730 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -134,8 +134,6 @@ Genre inconnu Dynamique Cyan - Lecture aléatoire sélectionnée - Réinitialiser Aucun dossier Supprimer le dossier Artiste inconnu diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5c3288e6a..faf684102 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -49,7 +49,6 @@ Reproducir Mezcla Reproducir seguinte - Reproducir a selección Cola Engadir á cola Excluir o que non é música @@ -126,7 +125,6 @@ Ascendente Descendente Ecualizador - Aleatorio seleccionado Frecuencia de mostraxe Acerca de Monitorizando cambios na túa biblioteca… diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f4e65894c..97ffbd5ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -100,8 +100,6 @@ %s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता। लोड किए गए गाने: %d अवरोही - चयनित चलाएँ - फेरबदल का चयन किया गया स्थिति साफ की गई स्थिति सहेजी गई लायब्रेरी टैब की दृश्यता और क्रम बदलें diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 2cd6aac4f..fecd2b6f2 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -247,8 +247,6 @@ Ponovo pretraži glazbu Izbriši predmemoriju oznaka i ponovo potpuno učitaj glazbenu biblioteku (sporije, ali potpunije) Odabrano: %d - Promiješaj odabrane - Reproduciraj odabrane Reproduciraj iz žanra Wiki %1$s, %2$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 179bb1a44..cec9b922a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -75,7 +75,6 @@ Név Dátum Csökkenő - Kiválasztott lejátszása Új lejátszólista Ismeretlen műfaj Ugrás a következő dalra @@ -142,7 +141,6 @@ Helyezze át ezt a dalt %s előadó fotója Teljes időtartam: %s - Kiválasztottak keverése UI vezérlők és viselkedés testreszabása A könyvtárfülek láthatóságának és sorrendjének módosítása A tétel részleteiből történő lejátszáskor diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 525ed9115..21f166a7e 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -183,8 +183,6 @@ Muat ulang otomatis Selalu muat ulang pustaka musik saat terjadi perubahan (membutuhkan notifikasi tetap) Perilaku - Putar yang dipilih - Acak yang dipilih Mode bundar Aktifkan sudut yang bundar pada elemen UI tambahan (mewajibkan sampul album bersudut bundar) Koma (,) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9e565a356..1a4bf8938 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -255,8 +255,6 @@ Impossibile salvare Svuota la cache dei tag e ricarica completamente la libreria musicale (più lento, ma più completo) Impossibile svuotare - Mescola selezionati - Riproduci selezionati %d selezionati Riproduci dal genere Wiki diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index ab1393f98..e85f5a07e 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -40,9 +40,7 @@ מושמע כעת איקוולייזר ניגון - ניגון נבחרים ערבוב - ערבוב נבחרים ניגון הבא הוספה לתור מעבר לאלבום diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 21849864b..7c011f04e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -63,7 +63,6 @@ 降順 再生 シャフル - 選択曲をシャフル 次に再生 再生待ちに追加 オーディオ形式 @@ -178,7 +177,6 @@ リミックスEP リミックス ジャンル - 選択曲を再生 プロパティを見る 再生待ち ライブラリ統計 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7d54c121c..70aa17ba9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -251,8 +251,6 @@ %d 아티스트 태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함) - 선택한 재생 - 선택한 셔플 %d 선택됨 재설정 위키 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index e225f4250..cfcbccdcf 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -249,8 +249,6 @@ Perskenuoti muziką Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) %d pasirinkta - Pasirinktas grojimas - Pasirinktas maišymas Groti iš žanro Viki %1$s, %2$s diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 82f0ffe92..f1ae5be53 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -1,6 +1,5 @@ - തിരഞ്ഞെടുത്തു കളിക്കുക രക്ഷിക്കുക പെരുമാറ്റം ഉള്ളടക്കം diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5913008c0..c276bd0bd 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -43,7 +43,6 @@ Sporantall Spill neste - Omstokking valgt Bibliotek Kunne ikke lagre tilstand @@ -275,7 +274,6 @@ Tonekontroll Endre gjentagelsesmodus Spill - Spill valgte Lagre Laster inn musikkbiblioteket ditt … Ved avspilling fra bibliotek diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4d29de5df..c0ecc5905 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -209,7 +209,6 @@ Toon alleen artiesten die rechtstreeks op een album worden genoemd (werkt het beste op goed getagde bibliotheken) Sorteer namen die beginnen met cijfers of woorden zoals \"de\" correct (werkt het beste met Engelstalige muziek) Stop met afspelen - Geselecteerd afspelen Uw muziekbibliotheek wordt geladen… Gedrag Remix compilatie @@ -279,7 +278,6 @@ %d artiest %d artiesten - Shuffle geselecteerd Intelligent sorteren Verschijnt op Afspeellijsten diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 8e43996db..175c77cdb 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -49,7 +49,6 @@ ਇਕੋਲਾਈਜ਼ਰ ਚਲਾਓ ਸ਼ਫਲ - ਸ਼ਫਲ ਚੁਣਿਆ ਗਿਆ ਕਤਾਰ ਅਗਲਾ ਚਲਾਓ ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ @@ -76,7 +75,6 @@ ਖੋਜੋ ਗੀਤ ਦੀ ਗਿਣਤੀ ਘਟਦੇ ਹੋਏ - ਚੁਣਿਆ ਹੋਇਆ ਚਲਾਓ ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ ਫਾਈਲ ਦਾ ਨਾਮ ਬਿੱਟ ਰੇਟ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e813bd244..737838fe6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -255,8 +255,6 @@ Stan odtwarzania Obrazy Zarządzaj dźwiękiem i odtwarzaniem muzyki - Odtwórz wybrane - Wybrane losowo Wybrano %d Wyrównanie głośności (ReplayGain) Resetuj diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ec43dcdc8..ef6191ce3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -253,8 +253,6 @@ 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 diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8140c3ad1..3a297776d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -217,8 +217,6 @@ 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 793b4ec7d..4351fd565 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -134,11 +134,9 @@ Afişa Utilizați o temă întunecată pur-negru Coperți rotunjite ale albumelor - Redare selecție Listă de redare Liste de redare Descrescător - Selecție aleatorie aleasă Treceți la următoarea Redă de la artist Redă din genul diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 03703707b..6e93fb54f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -258,8 +258,6 @@ Не удалось очистить состояние Не удалось сохранить состояние Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\). - Воспроизвести выбранное - Перемешать выбранное %d выбрано Вики Сбросить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index e9547d169..60f9e5c97 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -43,7 +43,6 @@ Nu spelar Utjämnare Spela - Spela utvalda Blanda Spela nästa @@ -99,7 +98,6 @@ Alla Disk Sortera - Blanda utvalda Lägg till kö Filnamn Lägg till diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 86915531e..559ef2336 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -196,8 +196,6 @@ Tekliler Tekli Karışık kaset - Seçileni çal - Karışık seçildi Canlı derleme Remiks derlemeler Ekolayzır diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fa3310d1c..ddeb997f0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -54,7 +54,6 @@ %d альбомів %d альбомів - Перемішати вибране Ім\'я файлу Формат Добре @@ -83,7 +82,6 @@ Шлях до каталогу Екран Рік - Відтворити вибране Обкладинки альбомів Приховати співавторів Вимкнено diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5edb4fc5e..c5e17d0c4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -249,8 +249,6 @@ 无法清除状态 重新扫描音乐 清除标签缓存并完全重新加载音乐库(更慢,但更完整) - 随机播放所选 - 播放所选 选中了 %d 首 按流派播放 Wiki diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 852e9eeae..31700900b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,9 +114,7 @@ Now playing Equalizer Play - Play selected Shuffle - Shuffle selected Queue Play next From 12bc46e210f7456cbc72bb666d50beb488590ece Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:42:48 -0600 Subject: [PATCH 074/127] build: bump to 3.2.0 Bump Auxio to version 3.2.0 (35). --- CHANGELOG.md | 5 +---- README.md | 4 ++-- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/35.txt | 2 ++ .../en-US/images/phoneScreenshots/shot5.png | Bin 83876 -> 95043 bytes 5 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/35.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d1a81e7..8fd76d375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev +## 3.2.0 #### What's New - Item and sort menus have been refreshed with a cleaner look @@ -16,9 +16,6 @@ aspect ratio setting #### What's Fixed - Playlist detail view now respects playback settings -#### Dev/Meta -- Unified navigation graph - ## 3.1.4 #### What's Fixed diff --git a/README.md b/README.md index a324ffd3c..8b16f36ed 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index efac4f42e..6ab20ef92 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.1.0" - versionCode 34 + versionName "3.2.0" + versionCode 35 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/35.txt b/fastlane/metadata/android/en-US/changelogs/35.txt new file mode 100644 index 000000000..e3514f6dc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/35.txt @@ -0,0 +1,2 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index 8fcac190a493a94397413a59818e23838fe26f39..1ed0f49ec4322ba3748ae74728c17821e380add1 100644 GIT binary patch literal 95043 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 of6pS=TT?yc|I0=dg`r8lS-m2vGp13EL>j{pDw literal 83876 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 DEDcez From 58989f8568bb4ce03202b7adb534d219bb209f15 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:58:38 -0600 Subject: [PATCH 075/127] build: revert to navigation 2.5.3 I would have to duplicate the workaround for every fragment in the project. Easier to just roll back until it's fixed. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 6 ------ build.gradle | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 2163721de..c86037eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,12 +148,6 @@ class MainFragment : } } - // Workaround for a bug where fast navigation ends up desynchronizing the current - // destination in the main navigation graph. - findNavController().apply { - findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } - } - // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) diff --git a/build.gradle b/build.gradle index e5bc1dc66..8713d9839 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.7.0" + navigation_version = "2.5.3" hilt_version = '2.47' } From d297c10b0ad8f4e69742b0fe4eb48ea8e0f1174e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:24:17 -0600 Subject: [PATCH 076/127] detail: fix crash on multi-artist navigation Caused by an unimplemented navigation branch in ArtistDetailFragment. --- .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 12 ++++++++++-- .../auxio/music/decision/AddToPlaylistDialog.kt | 2 +- app/src/main/res/navigation/inner.xml | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) 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 a611af6f4..b0bc09386 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -221,8 +221,16 @@ class ArtistDetailFragment : .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) } } - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, + is Show.SongArtistDecision -> { + logD("Navigating to artist choices for ${show.song}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid)) + } + is Show.AlbumArtistDecision -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid)) + } is Show.GenreDetails, is Show.PlaylistDetails -> { error("Unexpected show command $show") diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 57e2bdf7c..7e7aec2ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -76,7 +76,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) - collect(musicModel.playlistDecision.flow, ::handleDecision) + musicModel.playlistDecision.consume() collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index b1f834f95..a974b3360 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -263,6 +263,9 @@ + From 20c34fd888934562d49374327fcd5c49faef554c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:39:05 -0600 Subject: [PATCH 077/127] music: fix crash on adding to new playlist Apparently dialog fragments do not change the state of the fragment it is overlaid on, resulting in it still having active StateFlow collectors that will intercept new playlist requests before AddToPlaylistDialog. Once again sharing StateFlows across views has bit me. In the future I may try to preserve the navigation idioms by not stacking NewPlaylistDialog on AddToPlaylistDialog and instead simply swap them out. I think this would also be better design too (It's not like I'm allowing other decision dialogs to be exitable back to their prior dialog). --- .../music/decision/AddToPlaylistDialog.kt | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 7e7aec2ac..51dcfcf6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -32,10 +32,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe @@ -93,26 +91,16 @@ class AddToPlaylistDialog : } override fun onNewPlaylist() { - musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) - } - - private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Navigated to playlist add dialog") - musicModel.playlistDecision.consume() - } - is PlaylistDecision.New -> { - logD("Navigating to new playlist dialog") - findNavController() - .navigateSafe( - AddToPlaylistDialogDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - } - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + // TODO: This is a temporary fix. Eventually I want to make this navigate away and + // instead have primary fragments launch navigation to the new playlist dialog. + // This should be better design (dialog layering is uh... probably not good) and + // preserves the existing navigation system. + // I could also roll some kind of new playlist textbox into the dialog, but that's + // a lot harder. + val songs = pickerModel.currentSongsToAdd.value ?: return + findNavController() + .navigateSafe( + AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray())) } private fun updatePendingSongs(songs: List?) { From d0b34a14e4f7a4c6c460c9e7d6e942c293f6c2c4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:42:12 -0600 Subject: [PATCH 078/127] playback: fix broken item navigation Caused yet again by sharing StateFlows leading to a strange out-of-order collector notification, which then allows detail fragments to consume item navigation requests before the playback panel can even get them. SharedFlow doesn't help here, so we are just forced to move this to MainFragment which does not have this issue for some reason. --- .../java/org/oxycblt/auxio/MainFragment.kt | 21 ++++++++++++++++ .../auxio/playback/PlaybackPanelFragment.kt | 24 +------------------ 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..297bad95c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -38,6 +38,7 @@ import kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.list.ListViewModel @@ -49,6 +50,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.ViewBindingFragment +import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.coordinatorLayoutBehavior @@ -149,6 +151,11 @@ class MainFragment : } // --- VIEWMODEL SETUP --- + // This has to be done here instead of the playback panel to make sure that it's prioritized + // by StateFlow over any detail fragment. + // FIXME: This is a consequence of sharing events across several consumers. There has to be + // a better way of doing this. + collect(detailModel.toShow.flow, ::handleShow) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) @@ -287,6 +294,20 @@ class MainFragment : return true } + private fun handleShow(show: Show?) { + when (show) { + is Show.SongAlbumDetails, + is Show.ArtistDetails, + is Show.AlbumDetails -> playbackModel.openMain() + is Show.SongDetails, + is Show.SongArtistDecision, + is Show.AlbumArtistDecision, + is Show.GenreDetails, + is Show.PlaylistDetails, + null -> {} + } + } + private fun handleShowOuter(outer: Outer?) { val directions = when (outer) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b43b3330e..b7228d508 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -33,7 +33,6 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -41,7 +40,6 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick @@ -105,12 +103,7 @@ class PlaybackPanelFragment : // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { - playbackModel.song.value?.let { - detailModel.showAlbum(it) - playbackModel.openMain() - } - } + setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } } binding.playbackArtist.apply { isSelected = true @@ -138,7 +131,6 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) - collect(detailModel.toShow.flow, ::handleShow) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { @@ -220,20 +212,6 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun handleShow(show: Show?) { - when (show) { - is Show.SongAlbumDetails, - is Show.ArtistDetails, - is Show.AlbumDetails -> playbackModel.openMain() - is Show.SongDetails, - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, - is Show.GenreDetails, - is Show.PlaylistDetails, - null -> {} - } - } - private fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } From 2c2bd79ae2238dd599f9706cc0905556f8a4a159 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 11:47:50 -0600 Subject: [PATCH 079/127] music: trim simple names after punct removal Trim simple names once punctuation has been removed. This prevents situations where album names like "& Yet & Yet" (a real album by post-rock outfit Do Make Say Think) will have blank thumbs. This probably isn't the best approach in general, but nothing about the intelligent name system is a good approach. --- .../java/org/oxycblt/auxio/music/info/Name.kt | 7 +- .../org/oxycblt/auxio/music/info/DateTest.kt | 119 +----------------- .../org/oxycblt/auxio/music/info/NameTest.kt | 4 + 3 files changed, 11 insertions(+), 119 deletions(-) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index bbde5aca3..fb753f641 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -148,6 +148,8 @@ sealed interface Name : Comparable { private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } private val punctRegex by lazy { Regex("[\\p{Punct}+]") } +// TODO: Consider how you want to handle whitespace and "gaps" in names. + /** * Plain [Name.Known] implementation that is internationalization-safe. * @@ -159,7 +161,7 @@ private data class SimpleKnownName(override val raw: String, override val sort: private fun parseToken(name: String): SortToken { // Remove excess punctuation from the string, as those usually aren't considered in sorting. - val stripped = name.replace(punctRegex, "").ifEmpty { name } + val stripped = name.replace(punctRegex, "").trim().ifEmpty { name } val collationKey = collator.getCollationKey(stripped) // Always use lexicographic mode since we aren't parsing any numeric components return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) @@ -180,7 +182,8 @@ private data class IntelligentKnownName(override val raw: String, override val s // optimize it val stripped = name - // Remove excess punctuation from the string, as those u + // Remove excess punctuation from the string, as those usually aren't + // considered in sorting. .replace(punctRegex, "") .ifEmpty { name } .run { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 075df1b1c..40c95bc56 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -1,119 +1,4 @@ -/* - * Copyright (c) 2023 Auxio Project - * DateTest.kt is part of Auxio. - * - * 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.info -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class DateTest { - @Test - fun date_equals_varyingPrecision() { - assertTrue( - requireNotNull(Date.from("2016-08-16T00:01:02")) != - requireNotNull(Date.from("2016-08-16"))) - } - - @Test - 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_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_compareTo_varyingPrecision() { - 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_from_values() { - assertEquals("2016", Date.from(2016).toString()) - assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) - assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) - } - - @Test - fun date_from_yearDate() { - assertEquals("2016-08-16", Date.from(20160816).toString()) - assertEquals("2016-08-16", Date.from("20160816").toString()) - } - - @Test - 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( - 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_from_one() { - 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_from_none() { - assertEquals(null, Date.Range.from(listOf())) - } -} +class NameTest { +} \ No newline at end of file diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt new file mode 100644 index 000000000..40c95bc56 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -0,0 +1,4 @@ +package org.oxycblt.auxio.music.info + +class NameTest { +} \ No newline at end of file From 59e42acad901237054797a0ec2d81e846b2bb6cb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 11:51:09 -0600 Subject: [PATCH 080/127] test: re-implement tests Reimplement the tests of music modeling, and re-enable the automatic test workflow in GH actions. I'm actually going to work on reimplementing these. --- .github/workflows/android.yml | 4 +- app/build.gradle | 2 + .../java/org/oxycblt/auxio/music/info/Name.kt | 6 +- .../java/org/oxycblt/auxio/music/FakeMusic.kt | 143 ------ .../auxio/music/FakeMusicRepository.kt | 90 ---- .../oxycblt/auxio/music/FakeMusicSettings.kt | 77 ---- .../org/oxycblt/auxio/music/MusicModeTest.kt | 32 -- .../oxycblt/auxio/music/MusicViewModelTest.kt | 133 ------ .../auxio/music/device/DeviceMusicImplTest.kt | 186 -------- .../auxio/music/device/FakeDeviceLibrary.kt | 61 --- .../org/oxycblt/auxio/music/info/DateTest.kt | 91 +++- .../org/oxycblt/auxio/music/info/DiscTest.kt | 34 +- .../org/oxycblt/auxio/music/info/NameTest.kt | 428 +++++++++++++++++- .../auxio/music/metadata/TagUtilTest.kt | 31 +- .../auxio/music/metadata/TextTagsTest.kt | 5 + .../org/oxycblt/auxio/util/TestingUtil.kt | 28 -- 16 files changed, 564 insertions(+), 787 deletions(-) delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f0aff366e..f106a3aac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,8 +23,8 @@ jobs: cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - # - name: Test app with Gradle - # run: ./gradlew app:testDebug + - name: Test app with Gradle + run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact diff --git a/app/build.gradle b/app/build.gradle index 6ab20ef92..45fdff5ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -145,8 +145,10 @@ dependencies { // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" + testImplementation "io.mockk:mockk:1.13.7" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + } spotless { diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index fb753f641..c47e561cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.info import android.content.Context import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import java.text.CollationKey import java.text.Collator import org.oxycblt.auxio.music.MusicSettings @@ -54,10 +55,11 @@ sealed interface Name : Comparable { abstract val sort: String? /** A tokenized version of the name that will be compared. */ - protected abstract val sortTokens: List + @VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List /** An individual part of a name string that can be compared intelligently. */ - protected data class SortToken(val collationKey: CollationKey, val type: Type) : + @VisibleForTesting(VisibleForTesting.PROTECTED) + data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable { override fun compareTo(other: SortToken): Int { // Numeric tokens should always be lower than lexicographic tokens. diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt deleted file mode 100644 index 32d8c0df2..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusic.kt is part of Auxio. - * - * 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.net.Uri -import org.oxycblt.auxio.music.fs.MimeType -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.info.Date -import org.oxycblt.auxio.music.info.Disc -import org.oxycblt.auxio.music.info.Name -import org.oxycblt.auxio.music.info.ReleaseType - -open class FakeSong : Song { - override val name: Name - get() = throw NotImplementedError() - - override val date: Date? - get() = throw NotImplementedError() - - override val dateAdded: Long - get() = throw NotImplementedError() - - override val disc: Disc? - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override val mimeType: MimeType - get() = throw NotImplementedError() - - override val track: Int? - get() = throw NotImplementedError() - - override val path: Path - get() = throw NotImplementedError() - - override val size: Long - get() = throw NotImplementedError() - - override val uri: Uri - get() = throw NotImplementedError() - - override val album: Album - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeAlbum : Album { - override val name: Name - get() = throw NotImplementedError() - - override val coverUri: Uri - get() = throw NotImplementedError() - - override val dateAdded: Long - get() = throw NotImplementedError() - - override val dates: Date.Range? - get() = throw NotImplementedError() - - override val releaseType: ReleaseType - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeArtist : Artist { - override val name: Name - get() = throw NotImplementedError() - - override val albums: List - get() = throw NotImplementedError() - - override val explicitAlbums: List - get() = throw NotImplementedError() - - override val implicitAlbums: List - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeGenre : Genre { - override val name: Name - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt deleted file mode 100644 index 8c79f0e9a..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicRepository.kt is part of Auxio. - * - * 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 kotlinx.coroutines.Job -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary - -open class FakeMusicRepository : MusicRepository { - override val indexingState: IndexingState? - get() = throw NotImplementedError() - - override val deviceLibrary: DeviceLibrary? - get() = throw NotImplementedError() - - override val userLibrary: UserLibrary? - get() = throw NotImplementedError() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun registerWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun find(uid: Music.UID): Music? { - throw NotImplementedError() - } - - override suspend fun createPlaylist(name: String, songs: List) { - throw NotImplementedError() - } - - override suspend fun renamePlaylist(playlist: Playlist, name: String) { - throw NotImplementedError() - } - - override suspend fun deletePlaylist(playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun addToPlaylist(songs: List, playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { - throw NotImplementedError() - } - - override fun requestIndex(withCache: Boolean) { - throw NotImplementedError() - } - - override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean): Job { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt deleted file mode 100644 index 14924f4f1..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicSettings.kt is part of Auxio. - * - * 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.list.sort.Sort -import org.oxycblt.auxio.music.fs.MusicDirectories - -open class 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() - - 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 val intelligentSorting: Boolean - get() = 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 playlistSort: 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/MusicModeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt deleted file mode 100644 index c11985970..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicModeTest.kt is part of Auxio. - * - * 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 MusicModeTest { - @Test - fun intCode() { - assertEquals(MusicType.SONGS, MusicType.fromIntCode(MusicType.SONGS.intCode)) - assertEquals(MusicType.ALBUMS, MusicType.fromIntCode(MusicType.ALBUMS.intCode)) - assertEquals(MusicType.ARTISTS, MusicType.fromIntCode(MusicType.ARTISTS.intCode)) - assertEquals(MusicType.GENRES, MusicType.fromIntCode(MusicType.GENRES.intCode)) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt deleted file mode 100644 index b25c2c0b1..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicViewModelTest.kt is part of Auxio. - * - * 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.Assert.assertTrue -import org.junit.Test -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.device.FakeDeviceLibrary -import org.oxycblt.auxio.util.forceClear - -class MusicViewModelTest { - @Test - fun indexerState() { - val indexer = - TestMusicRepository().apply { - indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) - } - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - assertTrue(indexer.updateListener is MusicViewModel) - assertTrue(indexer.indexingListener is MusicViewModel) - assertEquals( - IndexingProgress.Indeterminate, - (musicViewModel.indexingState.value as IndexingState.Indexing).progress) - indexer.indexingState = null - assertEquals(null, musicViewModel.indexingState.value) - musicViewModel.forceClear() - assertTrue(indexer.indexingListener == null) - } - - @Test - fun statistics() { - val musicRepository = TestMusicRepository() - val musicViewModel = MusicViewModel(musicRepository, FakeMusicSettings()) - assertEquals(null, musicViewModel.statistics.value) - musicRepository.deviceLibrary = TestDeviceLibrary() - assertEquals( - MusicViewModel.Statistics( - 2, - 3, - 4, - 1, - 161616 * 2, - ), - musicViewModel.statistics.value) - } - - @Test - fun requests() { - val indexer = TestMusicRepository() - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - musicViewModel.refresh() - musicViewModel.rescan() - assertEquals(listOf(true, false), indexer.requests) - } - - private class TestMusicRepository : FakeMusicRepository() { - override var deviceLibrary: DeviceLibrary? = null - set(value) { - field = value - updateListener?.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - - override var indexingState: IndexingState? = null - set(value) { - field = value - indexingListener?.onIndexingStateChanged() - } - - var updateListener: MusicRepository.UpdateListener? = null - var indexingListener: MusicRepository.IndexingListener? = null - val requests = mutableListOf() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - this.updateListener = listener - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - this.updateListener = null - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - listener.onIndexingStateChanged() - this.indexingListener = listener - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - this.indexingListener = null - } - - override fun requestIndex(withCache: Boolean) { - requests.add(withCache) - } - } - - private class TestDeviceLibrary : FakeDeviceLibrary() { - override val songs: List - get() = listOf(TestSong(), TestSong()) - - override val albums: List - get() = listOf(FakeAlbum(), FakeAlbum(), FakeAlbum()) - - override val artists: List - get() = listOf(FakeArtist(), FakeArtist(), FakeArtist(), FakeArtist()) - - override val genres: List - get() = listOf(FakeGenre()) - } - - private class TestSong : FakeSong() { - override val durationMs: Long - get() = 161616 - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt deleted file mode 100644 index 2c4805486..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * DeviceMusicImplTest.kt is part of Auxio. - * - * 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.device - -import java.util.UUID -import org.junit.Assert.assertTrue -import org.junit.Test - -class DeviceMusicImplTest { - @Test - fun albumRaw_equals_inconsistentCase() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian Glow"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian glow"))) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun albumRaw_equals_withMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"), - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_inconsistentMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_withArtists() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist A"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist B"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentCase() { - val a = RawArtist(musicBrainzId = null, name = "Parannoul") - val b = RawArtist(musicBrainzId = null, name = "parannoul") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_withMbids() { - val a = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = - RawArtist( - 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 = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = RawArtist(musicBrainzId = null, name = "Artist") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_missingNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = "Parannoul") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentCase() { - val a = RawGenre("Future Garage") - val b = RawGenre("future garage") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_missingNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = "Future Garage") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt b/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt deleted file mode 100644 index dab0834a3..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeDeviceLibrary.kt is part of Auxio. - * - * 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.device - -import android.content.Context -import android.net.Uri -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.Song - -open class FakeDeviceLibrary : DeviceLibrary { - override val songs: List - get() = throw NotImplementedError() - - override val albums: List - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override fun findSong(uid: Music.UID): Song? { - throw NotImplementedError() - } - - override fun findSongForUri(context: Context, uri: Uri): Song? { - throw NotImplementedError() - } - - override fun findAlbum(uid: Music.UID): Album? { - throw NotImplementedError() - } - - override fun findArtist(uid: Music.UID): Artist? { - throw NotImplementedError() - } - - override fun findGenre(uid: Music.UID): Genre? { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 40c95bc56..b63639e27 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -1,4 +1,91 @@ +/* + * Copyright (c) 2023 Auxio Project + * DateTest.kt is part of Auxio. + * + * 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.info -class NameTest { -} \ No newline at end of file +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DateTest { + @Test + fun date_equals_varyingPrecision() { + assertTrue( + requireNotNull(Date.from("2016-08-16T00:01:02")) != + requireNotNull(Date.from("2016-08-16"))) + } + + @Test + 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_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_compareTo_varyingPrecision() { + 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_from_values() { + assertEquals("2016", Date.from(2016).toString()) + assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) + assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) + } + + @Test + fun date_from_yearDate() { + assertEquals("2016-08-16", Date.from(20160816).toString()) + assertEquals("2016-08-16", Date.from("20160816").toString()) + } + + @Test + 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()) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt index 260ca67cb..9b428acac 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt @@ -19,30 +19,36 @@ package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class DiscTest { @Test - fun disc_compare() { - val a = Disc(1, "Part I") - val b = Disc(2, "Part II") - assertEquals(-1, a.compareTo(b)) + fun disc_equals_byNum() { + val a = Disc(0, null) + val b = Disc(0, null) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) } @Test - fun disc_equals_correct() { - val a = Disc(1, "Part I") - val b = Disc(1, "Part I") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) + fun disc_equals_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(0, "a subtitle") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) } @Test - fun disc_equals_inconsistentNames() { - val a = Disc(1, "Part I") + fun disc_compareTo_byNum() { + val a = Disc(0, null) val b = Disc(1, null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun disc_compareTo_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(1, "a subtitle") + assertEquals(-1, a.compareTo(b)) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index 40c95bc56..9ede93b5e 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -1,4 +1,430 @@ +/* + * Copyright (c) 2023 Auxio Project + * NameTest.kt is part of Auxio. + * + * 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.info +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.oxycblt.auxio.music.MusicSettings + class NameTest { -} \ No newline at end of file + private fun mockIntelligentSorting(enabled: Boolean) = + mockk().apply { every { intelligentSorting } returns enabled } + + @Test + fun name_from_simple_withoutPunct() { + val name = Name.Known.from("Loveless", null, mockIntelligentSorting(false)) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_withPunct() { + val name = Name.Known.from("alt-J", null, mockIntelligentSorting(false)) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_oopsAllPunct() { + val name = Name.Known.from("!!!", null, mockIntelligentSorting(false)) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_spacedPunct() { + val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(false)) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_simple_withSort() { + val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(false)) + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("Loveless", null, mockIntelligentSorting(true)) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { + val name = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + assertEquals("15 Step", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("15", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Step", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { + val name = Name.Known.from("23Kid", null, mockIntelligentSorting(true)) + assertEquals("23Kid", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("23", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Kid", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { + val name = Name.Known.from("Foo 1 2 Bar", null, mockIntelligentSorting(true)) + assertEquals("Foo 1 2 Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("2", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("Bar", fifth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fifth.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { + val name = Name.Known.from("Foo12Bar", null, mockIntelligentSorting(true)) + assertEquals("Foo12Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("12", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals("Bar", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { + val name = Name.Known.from("Foo 1", null, mockIntelligentSorting(true)) + assertEquals("Foo 1", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { + val name = Name.Known.from("Error404", null, mockIntelligentSorting(true)) + assertEquals("Error404", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Error", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("404", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withThe_withoutNumerics() { + val name = Name.Known.from("The National Anthem", null, mockIntelligentSorting(true)) + assertEquals("The National Anthem", name.raw) + assertEquals(null, name.sort) + assertEquals("N", name.thumb) + val first = name.sortTokens[0] + assertEquals("National Anthem", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withAn_withoutNumerics() { + val name = Name.Known.from("An Eagle in Your Mind", null, mockIntelligentSorting(true)) + assertEquals("An Eagle in Your Mind", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Eagle in Your Mind", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withA_withoutNumerics() { + val name = Name.Known.from("A Song For Our Fathers", null, mockIntelligentSorting(true)) + assertEquals("A Song For Our Fathers", name.raw) + assertEquals(null, name.sort) + assertEquals("S", name.thumb) + val first = name.sortTokens[0] + assertEquals("Song For Our Fathers", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("alt-J", null, mockIntelligentSorting(true)) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("!!!", null, mockIntelligentSorting(true)) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_shortArticle_withNumerics() { + val name = Name.Known.from("the 1", null, mockIntelligentSorting(true)) + assertEquals("the 1", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("1", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + } + + @Test + fun name_from_intelligent_spacedPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(true)) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withPunct_withoutArticle_withNumerics() { + val name = Name.Known.from("Design : 2 : 3", null, mockIntelligentSorting(true)) + assertEquals("Design : 2 : 3", name.raw) + assertEquals(null, name.sort) + assertEquals("D", name.thumb) + val first = name.sortTokens[0] + assertEquals("Design", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("2", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("3", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + } + + @Test + fun name_from_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { + val name = Name.Known.from("2 + 2 = 5", null, mockIntelligentSorting(true)) + assertEquals("2 + 2 = 5", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("2", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals(" ", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + val third = name.sortTokens[2] + assertEquals("2", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, third.type) + val fourth = name.sortTokens[3] + assertEquals(" ", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("5", fifth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fifth.type) + } + + @Test + fun name_from_intelligent_withSort() { + val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(true)) + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_equals_simple() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val b = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + assertEquals(a, b) + } + + @Test + fun name_equals_differentSort() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val b = Name.Known.from("The Same", null, mockIntelligentSorting(false)) + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_equals_intelligent_differentTokens() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(true)) + val b = Name.Known.from("Same", "Same", mockIntelligentSorting(true)) + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", null, mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(false)) + val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(false)) + val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(false)) + val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { + val a = Name.Known.from("15 Step", null, mockIntelligentSorting(false)) + val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(false)) + val c = Name.Known.from("1969", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + } + + @Test + fun name_compareTo_simple_withPartialSort() { + val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withSort() { + val a = Name.Known.from("D", "A", mockIntelligentSorting(false)) + val b = Name.Known.from("C", "B", mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", null, mockIntelligentSorting(true)) + val b = Name.Known.from("B", null, mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(true)) + val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(true)) + val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(true)) + val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(true)) + assertEquals(1, a.compareTo(b)) + assertEquals(1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { + val a = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(true)) + val c = Name.Known.from("1969", null, mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, b.compareTo(c)) + assertEquals(-2, a.compareTo(c)) + } + + @Test + fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("D", "A", mockIntelligentSorting(true)) + val b = Name.Known.from("C", "B", mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index db340f187..5f6c67c89 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -18,27 +18,32 @@ package org.oxycblt.auxio.music.metadata +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test -import org.oxycblt.auxio.music.FakeMusicSettings +import org.oxycblt.auxio.music.MusicSettings class TagUtilTest { + private fun mockSeparators(separators: String) = + mockk().apply { every { multiValueSeparators } returns separators } + @Test fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(TestMusicSettings(","))) + assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(mockSeparators(","))) } @Test fun parseMultiValue_many() { assertEquals( - listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(TestMusicSettings(","))) + listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(mockSeparators(","))) } @Test fun parseMultiValue_several() { assertEquals( listOf("a", "b", "c", "d", "e", "f"), - listOf("a,b;c/d+e&f").parseMultiValue(TestMusicSettings(",;/+&"))) + listOf("a,b;c/d+e&f").parseMultiValue(mockSeparators(",;/+&"))) } @Test @@ -131,43 +136,37 @@ class TagUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("176", "178", "Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(TestMusicSettings(","))) + assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_singleSeparated() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(TestMusicSettings(","))) - } - - class TestMusicSettings(private val separators: String) : FakeMusicSettings() { - override var multiValueSeparators: String - get() = separators - set(_) = throw NotImplementedError() + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(mockSeparators(","))) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index 6cd22fdcb..73c5b926d 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -39,6 +39,7 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["APIC"]) } @Test @@ -51,6 +52,7 @@ class TextTagsTest { 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"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) } @Test @@ -62,10 +64,13 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) + 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(null, textTags.id3v2["APIC"]) assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) } diff --git a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt b/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt deleted file mode 100644 index 5da90ab54..000000000 --- a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * TestingUtil.kt is part of Auxio. - * - * 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.util - -import androidx.lifecycle.ViewModel - -private val VM_CLEAR_METHOD = - ViewModel::class.java.getDeclaredMethod("clear").apply { isAccessible = true } - -fun ViewModel.forceClear() { - VM_CLEAR_METHOD.invoke(this) -} From c1655a9ecad30a6ac66e48d8c92062a6b1c8d7e9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 14:11:25 -0600 Subject: [PATCH 081/127] music: move multi-value util to separators Move all multi-value utilities to a new Separators interface. This should allow separator config to be dynamically compared across song instances, and generally make songs easier to test. --- .../org/oxycblt/auxio/music/MusicSettings.kt | 4 +- .../auxio/music/device/DeviceLibrary.kt | 5 +- .../auxio/music/device/DeviceMusicImpl.kt | 126 ++++++++++-------- .../auxio/music/metadata/Separators.kt | 59 ++++++++ .../auxio/music/metadata/SeparatorsDialog.kt | 13 +- .../oxycblt/auxio/music/metadata/TagUtil.kt | 43 +----- .../org/oxycblt/auxio/music/info/NameTest.kt | 2 +- .../auxio/music/metadata/SeparatorsTest.kt | 47 +++++++ .../auxio/music/metadata/TagUtilTest.kt | 41 +----- 9 files changed, 197 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt 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 f2930d3ec..488c98126 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -43,7 +43,7 @@ interface MusicSettings : Settings { /** 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 + var separators: String /** Whether to enable more advanced sorting by articles and numbers. */ val intelligentSorting: Boolean @@ -85,7 +85,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context override val shouldBeObserving: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) - override var multiValueSeparators: String + override var separators: 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(getString(R.string.set_key_separators), "") ?: "" diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index cd75ba578..eae4265ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull @@ -118,6 +119,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu rawSongs: Channel, processedSongs: Channel ): DeviceLibraryImpl { + val separators = Separators.from(musicSettings.separators) + val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() val artistGrouping = mutableMapOf>() @@ -127,7 +130,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // All music information is grouped as it is indexed by other components. for (rawSong in rawSongs) { - val song = SongImpl(rawSong, musicSettings) + val song = SongImpl(rawSong, musicSettings, separators) // At times the indexer produces duplicate songs, try to filter these. Comparing by // UID is sufficient for something like this, and also prevents collisions from // causing severe issues elsewhere. diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 1d2ce2a26..b8b20f06b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -36,8 +36,8 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.parseId3GenreNames -import org.oxycblt.auxio.music.metadata.parseMultiValue import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.positiveOrNull import org.oxycblt.auxio.util.toUuidOrNull @@ -48,10 +48,12 @@ import org.oxycblt.auxio.util.update * Library-backed implementation of [Song]. * * @param rawSong The [RawSong] to derive the member data from. + * @param separators The [Separators] to parse multi-value tags with. * @param musicSettings [MusicSettings] to for user parsing configuration. * @author Alexander Capehart (OxygenCobalt) */ -class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song { +class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separators: Separators) : + Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) } @@ -95,88 +97,100 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } + private var _album: AlbumImpl? = null override val album: Album get() = unlikelyToBeNull(_album) + private val _artists = mutableListOf() + override val artists: List + get() = _artists + + private val _genres = mutableListOf() + override val genres: List + get() = _genres + private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() override fun hashCode() = hashCode + // TODO: I cant compare by raw information actually, as it also means that any settings + // configuration will be lost as well. override fun equals(other: Any?) = other is SongImpl && uid == other.uid && rawSong == other.rawSong override fun toString() = "Song(uid=$uid, name=$name)" - private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings) - private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) - private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings) - private val rawIndividualArtists = - artistNames.mapIndexed { i, name -> - RawArtist( - artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), - name, - artistSortNames.getOrNull(i)) - } - - private val albumArtistMusicBrainzIds = - rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) - private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings) - private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings) - private val rawAlbumArtists = - albumArtistNames.mapIndexed { i, name -> - RawArtist( - albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), - name, - albumArtistSortNames.getOrNull(i)) - } - - private val _artists = mutableListOf() - override val artists: List - get() = _artists - - private val _genres = mutableListOf() - override val genres: List - get() = _genres - /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * [Album]. */ - val rawAlbum = - RawAlbum( - mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, - musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), - name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, - sortName = rawSong.albumSortName, - releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)), - rawArtists = - rawAlbumArtists - .ifEmpty { rawIndividualArtists } - .distinctBy { it.key } - .ifEmpty { listOf(RawArtist(null, null)) }) + val rawAlbum: RawAlbum /** * The [RawArtist] instances collated by the [Song]. The artists of the song take priority, * followed by the album artists. If there are no artists, this field will be a single "unknown" * [RawArtist]. This can be used to group up [Song]s into an [Artist]. */ - val rawArtists = - rawIndividualArtists - .ifEmpty { rawAlbumArtists } - .distinctBy { it.key } - .ifEmpty { listOf(RawArtist()) } + val rawArtists: List /** * The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a * [Genre]. ID3v2 Genre names are automatically converted to their resolved names. */ - val rawGenres = - rawSong.genreNames - .parseId3GenreNames(musicSettings) - .map { RawGenre(it) } - .distinctBy { it.key } - .ifEmpty { listOf(RawGenre()) } + val rawGenres: List + + init { + val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds) + val artistNames = separators.split(rawSong.artistNames) + val artistSortNames = separators.split(rawSong.artistSortNames) + val rawIndividualArtists = + artistNames + .mapIndexedTo(mutableSetOf()) { i, name -> + RawArtist( + artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + artistSortNames.getOrNull(i)) + } + .toList() + + val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) + val albumArtistNames = separators.split(rawSong.albumArtistNames) + val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) + val rawAlbumArtists = + albumArtistNames + .mapIndexedTo(mutableSetOf()) { i, name -> + RawArtist( + albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + albumArtistSortNames.getOrNull(i)) + } + .toList() + + rawAlbum = + RawAlbum( + mediaStoreId = + requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, + musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), + name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, + sortName = rawSong.albumSortName, + releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)), + rawArtists = + rawAlbumArtists + .ifEmpty { rawIndividualArtists } + .ifEmpty { listOf(RawArtist()) }) + + rawArtists = + rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) } + + val genreNames = + (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) + rawGenres = + genreNames + .mapTo(mutableSetOf()) { RawGenre(it) } + .toList() + .ifEmpty { listOf(RawGenre()) } + } /** * Links this [Song] with a parent [Album]. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt new file mode 100644 index 000000000..5142c6905 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 Auxio Project + * Separators.kt is part of Auxio. + * + * 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.metadata + +/** + * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags + * that may be delimited with a separator character. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface Separators { + /** + * Parse a separated value from one or more strings. If the value is already composed of more + * than one value, nothing is done. Otherwise, it will attempt to split it based on the user's + * separator preferences. + * + * @return A new list of one or more [String]s parsed by the separator configuration + */ + fun split(strings: List): List + + companion object { + const val COMMA = ',' + const val SEMICOLON = ';' + const val SLASH = '/' + const val PLUS = '+' + const val AND = '&' + + fun from(selector: String) = + if (selector.isNotEmpty()) CharSeparators(selector.toSet()) else NoSeparators + } +} + +private data class CharSeparators(private val chars: Set) : Separators { + override fun split(strings: List) = + if (strings.size == 1) splitImpl(strings.first()) else strings + + private fun splitImpl(string: String) = + string.splitEscaped { chars.contains(it) }.correctWhitespace() +} + +private object NoSeparators : Separators { + override fun split(strings: List) = strings +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index d74b9ba53..31195c408 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -52,7 +52,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment - musicSettings.multiValueSeparators = getCurrentSeparators() + musicSettings.separators = getCurrentSeparators() } } @@ -68,8 +68,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment binding.separatorComma.isChecked = true @@ -102,14 +101,6 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment.parseMultiValue(settings: MusicSettings) = - if (size == 1) { - first().maybeParseBySeparators(settings) - } else { - // Nothing to do. - this - } - // TODO: Remove the escaping checks, it's too expensive to do this for every single tag. /** @@ -101,17 +84,6 @@ fun String.correctWhitespace() = trim().ifBlank { null } */ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } -/** - * Attempt to parse a string by the user's separator preferences. - * - * @param settings [MusicSettings] 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(settings: MusicSettings): List { - if (settings.multiValueSeparators.isEmpty()) return listOf(this) - return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace() -} - /// --- ID3v2 PARSING --- /** @@ -165,12 +137,12 @@ fun transformPositionField(pos: Int?, total: Int?) = * representations of genre fields into their named counterparts, and split up singular ID3v2-style * integer genre fields into one or more genres. * - * @param settings [MusicSettings] required to obtain user separator configuration. - * @return A list of one or more genre names.. + * @return A list of one or more genre names, or null if this multi-value list has no valid + * formatting. */ -fun List.parseId3GenreNames(settings: MusicSettings) = +fun List.parseId3GenreNames() = if (size == 1) { - first().parseId3MultiValueGenre(settings) + first().parseId3MultiValueGenre() } else { // Nothing to split, just map any ID3v1 genres to their name counterparts. map { it.parseId3v1Genre() ?: it } @@ -179,11 +151,10 @@ fun List.parseId3GenreNames(settings: MusicSettings) = /** * 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. + * @return list of one or more genre names, or null if this is not in ID3v2 format. */ -private fun String.parseId3MultiValueGenre(settings: MusicSettings) = - parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings) +private fun String.parseId3MultiValueGenre() = + parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() /** * Parse an ID3v1 integer genre field. diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index 9ede93b5e..f23f76f16 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.MusicSettings class NameTest { private fun mockIntelligentSorting(enabled: Boolean) = - mockk().apply { every { intelligentSorting } returns enabled } + mockk() { every { intelligentSorting } returns enabled } @Test fun name_from_simple_withoutPunct() { diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt new file mode 100644 index 000000000..440f044e3 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Auxio Project + * SeparatorsTest.kt is part of Auxio. + * + * 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.metadata + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeparatorsTest { + @Test + fun separators_split_withString_withSingleChar() { + assertEquals(listOf("a", "b", "c"), Separators.from(",").split(listOf("a,b,c"))) + } + + @Test + fun separators_split_withMultiple_withSingleChar() { + assertEquals(listOf("a,b", "c", "d"), Separators.from(",").split(listOf("a,b", "c", "d"))) + } + + @Test + fun separators_split_withString_withMultipleChar() { + assertEquals( + listOf("a", "b", "c", "d", "e", "f"), + Separators.from(",;/+&").split(listOf("a,b;c/d+e&f"))) + } + + @Test + fun separators_split_withList_withMultipleChar() { + assertEquals( + listOf("a,b;c/d", "e&f"), Separators.from(",;/+&").split(listOf("a,b;c/d", "e&f"))) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index 5f6c67c89..7c900d42c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -18,34 +18,10 @@ package org.oxycblt.auxio.music.metadata -import io.mockk.every -import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test -import org.oxycblt.auxio.music.MusicSettings class TagUtilTest { - private fun mockSeparators(separators: String) = - mockk().apply { every { multiValueSeparators } returns separators } - - @Test - fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(mockSeparators(","))) - } - - @Test - fun parseMultiValue_many() { - assertEquals( - listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(mockSeparators(","))) - } - - @Test - fun parseMultiValue_several() { - assertEquals( - listOf("a", "b", "c", "d", "e", "f"), - listOf("a,b;c/d+e&f").parseMultiValue(mockSeparators(",;/+&"))) - } - @Test fun splitEscaped_correct() { assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' }) @@ -136,37 +112,30 @@ class TagUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("176", "178", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(mockSeparators(","))) + assertEquals(null, listOf("2941").parseId3GenreNames()) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(mockSeparators(","))) - } - - @Test - fun parseId3v2Genre_singleSeparated() { - assertEquals( - listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames()) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(mockSeparators(","))) + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames()) } } From fcffb560210a901e0d78cf2e226f23f5536b0295 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:27:45 -0600 Subject: [PATCH 082/127] music: use factory to create known names Implement a new Name.Known.Factory instance that replaces the usage of Name.Known.from. This again allows songs to be differentiated on tag interpretation and is generally easier to test. --- .../oxycblt/auxio/music/MusicRepository.kt | 3 +- .../auxio/music/device/DeviceLibrary.kt | 14 +- .../auxio/music/device/DeviceMusicImpl.kt | 37 ++- .../java/org/oxycblt/auxio/music/info/Name.kt | 100 ++++--- .../auxio/music/metadata/Separators.kt | 20 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 1 - .../oxycblt/auxio/music/user/PlaylistImpl.kt | 22 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 16 +- .../org/oxycblt/auxio/music/info/NameTest.kt | 257 ++++++++++-------- 9 files changed, 263 insertions(+), 207 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..662eb49c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -223,7 +223,8 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, private val deviceLibraryFactory: DeviceLibrary.Factory, - private val userLibraryFactory: UserLibrary.Factory + private val userLibraryFactory: UserLibrary.Factory, + private val musicSettings: MusicSettings ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index eae4265ff..739faba8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull @@ -108,7 +109,7 @@ interface DeviceLibrary { */ suspend fun create( rawSongs: Channel, - processedSongs: Channel + processedSongs: Channel, ): DeviceLibraryImpl } } @@ -119,7 +120,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu rawSongs: Channel, processedSongs: Channel ): DeviceLibraryImpl { - val separators = Separators.from(musicSettings.separators) + val nameFactory = Name.Known.Factory.from(musicSettings) + val separators = Separators.from(musicSettings) val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() @@ -130,7 +132,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // All music information is grouped as it is indexed by other components. for (rawSong in rawSongs) { - val song = SongImpl(rawSong, musicSettings, separators) + val song = SongImpl(rawSong, nameFactory, separators) // At times the indexer produces duplicate songs, try to filter these. Comparing by // UID is sufficient for something like this, and also prevents collisions from // causing severe issues elsewhere. @@ -210,7 +212,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // Now that all songs are processed, also process albums and group them into their // respective artists. - val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, musicSettings) } + val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, nameFactory) } for (album in albums) { for (rawArtist in album.rawArtists) { val key = RawArtist.Key(rawArtist) @@ -246,8 +248,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu } // Artists and genres do not need to be grouped and can be processed immediately. - val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, musicSettings) } - val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, musicSettings) } + val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, nameFactory) } + val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, nameFactory) } return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index b8b20f06b..d513fcbff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -25,7 +25,6 @@ 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.MusicSettings import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.MimeType @@ -48,12 +47,15 @@ import org.oxycblt.auxio.util.update * Library-backed implementation of [Song]. * * @param rawSong The [RawSong] to derive the member data from. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @param separators The [Separators] to parse multi-value tags with. - * @param musicSettings [MusicSettings] to for user parsing configuration. * @author Alexander Capehart (OxygenCobalt) */ -class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separators: Separators) : - Song { +class SongImpl( + private val rawSong: RawSong, + nameFactory: Name.Known.Factory, + separators: Separators +) : Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) } @@ -72,10 +74,8 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separ update(rawSong.albumArtistNames) } override val name = - Name.Known.from( - requireNotNull(rawSong.name) { "Invalid raw: No title" }, - rawSong.sortName, - musicSettings) + nameFactory.parse( + requireNotNull(rawSong.name) { "Invalid raw: No title" }, rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } @@ -256,13 +256,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separ * Library-backed implementation of [Album]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumImpl( - grouping: Grouping, - musicSettings: MusicSettings, -) : Album { +class AlbumImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Album { private val rawAlbum = grouping.raw.inner override val uid = @@ -275,7 +272,7 @@ class AlbumImpl( update(rawAlbum.name) update(rawAlbum.rawArtists.map { it.name }) } - override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) + override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName) override val dates: Date.Range? override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri) @@ -376,10 +373,10 @@ class AlbumImpl( * Library-backed implementation of [Artist]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistImpl(grouping: Grouping, musicSettings: MusicSettings) : Artist { +class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Artist { private val rawArtist = grouping.raw.inner override val uid = @@ -387,7 +384,7 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) } ?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) } override val name = - rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) } + rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) } ?: Name.Unknown(R.string.def_artist) override val songs: Set @@ -473,15 +470,15 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti * Library-backed implementation of [Genre]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class GenreImpl(grouping: Grouping, musicSettings: MusicSettings) : Genre { +class GenreImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Genre { private val rawGenre = grouping.raw.inner override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) } override val name = - rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } + rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) } ?: Name.Unknown(R.string.def_genre) override val songs: Set diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index c47e561cf..b0f0b029d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -57,36 +57,6 @@ sealed interface Name : Comparable { /** A tokenized version of the name that will be compared. */ @VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List - /** An individual part of a name string that can be compared intelligently. */ - @VisibleForTesting(VisibleForTesting.PROTECTED) - data class SortToken(val collationKey: CollationKey, val type: Type) : - Comparable { - override fun compareTo(other: SortToken): Int { - // Numeric tokens should always be lower than lexicographic tokens. - val modeComp = type.compareTo(other.type) - if (modeComp != 0) { - return modeComp - } - - // Numeric strings must be ordered by magnitude, thus immediately short-circuit - // the comparison if the lengths do not match. - if (type == Type.NUMERIC && - collationKey.sourceString.length != other.collationKey.sourceString.length) { - return collationKey.sourceString.length - other.collationKey.sourceString.length - } - - return collationKey.compareTo(other.collationKey) - } - - /** Denotes the type of comparison to be performed with this token. */ - enum class Type { - /** Compare as a digit string, like "65". */ - NUMERIC, - /** Compare as a standard alphanumeric string, like "65daysofstatic" */ - LEXICOGRAPHIC - } - } - final override val thumb: String get() = // TODO: Remove these safety checks once you have real unit testing @@ -110,20 +80,30 @@ sealed interface Name : Comparable { is Unknown -> 1 } - companion object { + interface Factory { /** * Create a new instance of [Name.Known] * * @param raw The raw name obtained from the music item * @param sort The raw sort name obtained from the music item - * @param musicSettings [MusicSettings] required for name configuration. */ - fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known = - if (musicSettings.intelligentSorting) { - IntelligentKnownName(raw, sort) - } else { - SimpleKnownName(raw, sort) - } + fun parse(raw: String, sort: String?): Known + + companion object { + /** + * Creates a new instance from the **current state** of the given [MusicSettings]'s + * user-defined name configuration. + * + * @param settings The [MusicSettings] to use. + * @return A new [Factory] instance reflecting the configuration state. + */ + fun from(settings: MusicSettings) = + if (settings.intelligentSorting) { + IntelligentKnownName.Factory() + } else { + SimpleKnownName.Factory() + } + } } } @@ -157,8 +137,8 @@ private val punctRegex by lazy { Regex("[\\p{Punct}+]") } * * @author Alexander Capehart (OxygenCobalt) */ -private data class SimpleKnownName(override val raw: String, override val sort: String?) : - Name.Known() { +@VisibleForTesting +data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = listOf(parseToken(sort ?: raw)) private fun parseToken(name: String): SortToken { @@ -168,6 +148,10 @@ private data class SimpleKnownName(override val raw: String, override val sort: // Always use lexicographic mode since we aren't parsing any numeric components return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) } + + class Factory : Name.Known.Factory { + override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) + } } /** @@ -175,7 +159,8 @@ private data class SimpleKnownName(override val raw: String, override val sort: * * @author Alexander Capehart (OxygenCobalt) */ -private data class IntelligentKnownName(override val raw: String, override val sort: String?) : +@VisibleForTesting +data class IntelligentKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = parseTokens(sort ?: raw) @@ -223,7 +208,40 @@ private data class IntelligentKnownName(override val raw: String, override val s } } + class Factory : Name.Known.Factory { + override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) + } + companion object { private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } } } + +/** An individual part of a name string that can be compared intelligently. */ +@VisibleForTesting(VisibleForTesting.PROTECTED) +data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable { + override fun compareTo(other: SortToken): Int { + // Numeric tokens should always be lower than lexicographic tokens. + val modeComp = type.compareTo(other.type) + if (modeComp != 0) { + return modeComp + } + + // Numeric strings must be ordered by magnitude, thus immediately short-circuit + // the comparison if the lengths do not match. + if (type == Type.NUMERIC && + collationKey.sourceString.length != other.collationKey.sourceString.length) { + return collationKey.sourceString.length - other.collationKey.sourceString.length + } + + return collationKey.compareTo(other.collationKey) + } + + /** Denotes the type of comparison to be performed with this token. */ + enum class Type { + /** Compare as a digit string, like "65". */ + NUMERIC, + /** Compare as a standard alphanumeric string, like "65daysofstatic" */ + LEXICOGRAPHIC + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 5142c6905..989a7b128 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -18,6 +18,9 @@ package org.oxycblt.auxio.music.metadata +import androidx.annotation.VisibleForTesting +import org.oxycblt.auxio.music.MusicSettings + /** * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags * that may be delimited with a separator character. @@ -40,9 +43,22 @@ interface Separators { const val SLASH = '/' const val PLUS = '+' const val AND = '&' + /** + * Creates a new instance from the **current state** of the given [MusicSettings]'s + * user-defined separator configuration. + * + * @param settings The [MusicSettings] to use. + * @return A new [Separators] instance reflecting the configuration state. + */ + fun from(settings: MusicSettings) = from(settings.separators) - fun from(selector: String) = - if (selector.isNotEmpty()) CharSeparators(selector.toSet()) else NoSeparators + @VisibleForTesting + fun from(chars: String) = + if (chars.isNotEmpty()) { + CharSeparators(chars.toSet()) + } else { + NoSeparators + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index fae02585e..196c7c0dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -77,7 +77,6 @@ private class TagWorkerImpl( private val rawSong: RawSong, private val future: Future ) : TagWorker { - override fun poll(): RawSong? { if (!future.isDone) { // Not done yet, nothing to do. diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index ffe7a5174..fe4418894 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.user import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song @@ -51,10 +50,10 @@ private constructor( * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. * * @param name The new name to use. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ - fun edit(name: String, musicSettings: MusicSettings) = - PlaylistImpl(uid, Name.Known.from(name, null, musicSettings), songs) + fun edit(name: String, nameFactory: Name.Known.Factory) = + PlaylistImpl(uid, nameFactory.parse(name, null), songs) /** * Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s. @@ -76,29 +75,26 @@ private constructor( * * @param name The name of the playlist. * @param songs The songs to initially populate the playlist with. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ - fun from(name: String, songs: List, musicSettings: MusicSettings) = - PlaylistImpl( - Music.UID.auxio(MusicType.PLAYLISTS), - Name.Known.from(name, null, musicSettings), - songs) + fun from(name: String, songs: List, nameFactory: Name.Known.Factory) = + PlaylistImpl(Music.UID.auxio(MusicType.PLAYLISTS), nameFactory.parse(name, null), songs) /** * Populate a new instance from a read [RawPlaylist]. * * @param rawPlaylist The [RawPlaylist] to read from. * @param deviceLibrary The [DeviceLibrary] to initialize from. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ fun fromRaw( rawPlaylist: RawPlaylist, deviceLibrary: DeviceLibrary, - musicSettings: MusicSettings + nameFactory: Name.Known.Factory ) = PlaylistImpl( rawPlaylist.playlistInfo.playlistUid, - Name.Known.from(rawPlaylist.playlistInfo.name, null, musicSettings), + nameFactory.parse(rawPlaylist.playlistInfo.name, null), rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 06de6d64f..faae9594b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -144,7 +145,9 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus UserLibrary.Factory { override suspend fun query() = try { - playlistDao.readRawPlaylists() + val rawPlaylists = playlistDao.readRawPlaylists() + logD("Successfully read ${rawPlaylists.size} playlists") + rawPlaylists } catch (e: Exception) { logE("Unable to read playlists: $e") listOf() @@ -154,11 +157,10 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus rawPlaylists: List, deviceLibrary: DeviceLibrary ): MutableUserLibrary { - logD("Successfully read ${rawPlaylists.size} playlists") - // Convert the database playlist information to actual usable playlists. + val nameFactory = Name.Known.Factory.from(musicSettings) val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { - val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings) + val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory) playlistMap[playlistImpl.uid] = playlistImpl } return UserLibraryImpl(playlistDao, playlistMap, musicSettings) @@ -184,7 +186,7 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override suspend fun createPlaylist(name: String, songs: List): Playlist? { - val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) + val playlistImpl = PlaylistImpl.from(name, songs, Name.Known.Factory.from(musicSettings)) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( @@ -207,7 +209,9 @@ private class UserLibraryImpl( val playlistImpl = synchronized(this) { requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - .also { playlistMap[it.uid] = it.edit(name, musicSettings) } + .also { + playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings)) + } } return try { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index f23f76f16..fd80d51c4 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -22,342 +22,352 @@ import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.oxycblt.auxio.music.MusicSettings class NameTest { - private fun mockIntelligentSorting(enabled: Boolean) = - mockk() { every { intelligentSorting } returns enabled } + @Test + fun name_simple_from_settings() { + val musicSettings = mockk { every { intelligentSorting } returns false } + assertTrue(Name.Known.Factory.from(musicSettings) is SimpleKnownName.Factory) + } + + @Test + fun name_intelligent_from_settings() { + val musicSettings = mockk { every { intelligentSorting } returns true } + assertTrue(Name.Known.Factory.from(musicSettings) is IntelligentKnownName.Factory) + } @Test - fun name_from_simple_withoutPunct() { - val name = Name.Known.from("Loveless", null, mockIntelligentSorting(false)) + fun name_simple_withoutPunct() { + val name = SimpleKnownName("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) val only = name.sortTokens.single() assertEquals("Loveless", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_withPunct() { - val name = Name.Known.from("alt-J", null, mockIntelligentSorting(false)) + fun name_simple_withPunct() { + val name = SimpleKnownName("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) val only = name.sortTokens.single() assertEquals("altJ", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_oopsAllPunct() { - val name = Name.Known.from("!!!", null, mockIntelligentSorting(false)) + fun name_simple_oopsAllPunct() { + val name = SimpleKnownName("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) val only = name.sortTokens.single() assertEquals("!!!", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_spacedPunct() { - val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(false)) + fun name_simple_spacedPunct() { + val name = SimpleKnownName("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) val first = name.sortTokens[0] assertEquals("Yet Yet", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_simple_withSort() { - val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(false)) + fun name_simple_withSort() { + val name = SimpleKnownName("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) val only = name.sortTokens.single() assertEquals("Smile", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("Loveless", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) val only = name.sortTokens.single() assertEquals("Loveless", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { - val name = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { + val name = IntelligentKnownName("15 Step", null) assertEquals("15 Step", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("15", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals("Step", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { - val name = Name.Known.from("23Kid", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { + val name = IntelligentKnownName("23Kid", null) assertEquals("23Kid", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("23", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals("Kid", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { - val name = Name.Known.from("Foo 1 2 Bar", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { + val name = IntelligentKnownName("Foo 1 2 Bar", null) assertEquals("Foo 1 2 Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("1", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals(" ", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) val fourth = name.sortTokens[3] assertEquals("2", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + assertEquals(SortToken.Type.NUMERIC, fourth.type) val fifth = name.sortTokens[4] assertEquals("Bar", fifth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fifth.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fifth.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { - val name = Name.Known.from("Foo12Bar", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { + val name = IntelligentKnownName("Foo12Bar", null) assertEquals("Foo12Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("12", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals("Bar", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { - val name = Name.Known.from("Foo 1", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { + val name = IntelligentKnownName("Foo 1", null) assertEquals("Foo 1", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("1", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { - val name = Name.Known.from("Error404", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { + val name = IntelligentKnownName("Error404", null) assertEquals("Error404", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) val first = name.sortTokens[0] assertEquals("Error", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("404", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withThe_withoutNumerics() { - val name = Name.Known.from("The National Anthem", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withThe_withoutNumerics() { + val name = IntelligentKnownName("The National Anthem", null) assertEquals("The National Anthem", name.raw) assertEquals(null, name.sort) assertEquals("N", name.thumb) val first = name.sortTokens[0] assertEquals("National Anthem", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withoutPunct_withAn_withoutNumerics() { - val name = Name.Known.from("An Eagle in Your Mind", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withAn_withoutNumerics() { + val name = IntelligentKnownName("An Eagle in Your Mind", null) assertEquals("An Eagle in Your Mind", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) val first = name.sortTokens[0] assertEquals("Eagle in Your Mind", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withoutPunct_withA_withoutNumerics() { - val name = Name.Known.from("A Song For Our Fathers", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withA_withoutNumerics() { + val name = IntelligentKnownName("A Song For Our Fathers", null) assertEquals("A Song For Our Fathers", name.raw) assertEquals(null, name.sort) assertEquals("S", name.thumb) val first = name.sortTokens[0] assertEquals("Song For Our Fathers", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("alt-J", null, mockIntelligentSorting(true)) + fun name_intelligent_withPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) val only = name.sortTokens.single() assertEquals("altJ", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("!!!", null, mockIntelligentSorting(true)) + fun name_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) val only = name.sortTokens.single() assertEquals("!!!", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_shortArticle_withNumerics() { - val name = Name.Known.from("the 1", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_shortArticle_withNumerics() { + val name = IntelligentKnownName("the 1", null) assertEquals("the 1", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("1", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) } @Test - fun name_from_intelligent_spacedPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(true)) + fun name_intelligent_spacedPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) val first = name.sortTokens[0] assertEquals("Yet Yet", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withPunct_withoutArticle_withNumerics() { - val name = Name.Known.from("Design : 2 : 3", null, mockIntelligentSorting(true)) + fun name_intelligent_withPunct_withoutArticle_withNumerics() { + val name = IntelligentKnownName("Design : 2 : 3", null) assertEquals("Design : 2 : 3", name.raw) assertEquals(null, name.sort) assertEquals("D", name.thumb) val first = name.sortTokens[0] assertEquals("Design", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("2", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals(" ", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) val fourth = name.sortTokens[3] assertEquals("3", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + assertEquals(SortToken.Type.NUMERIC, fourth.type) } @Test - fun name_from_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { - val name = Name.Known.from("2 + 2 = 5", null, mockIntelligentSorting(true)) + fun name_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { + val name = IntelligentKnownName("2 + 2 = 5", null) assertEquals("2 + 2 = 5", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("2", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals(" ", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) val third = name.sortTokens[2] assertEquals("2", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, third.type) + assertEquals(SortToken.Type.NUMERIC, third.type) val fourth = name.sortTokens[3] assertEquals(" ", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fourth.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fourth.type) val fifth = name.sortTokens[4] assertEquals("5", fifth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fifth.type) + assertEquals(SortToken.Type.NUMERIC, fifth.type) } @Test - fun name_from_intelligent_withSort() { - val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(true)) + fun name_intelligent_withSort() { + val name = IntelligentKnownName("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) val only = name.sortTokens.single() assertEquals("Smile", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test fun name_equals_simple() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) - val b = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val a = SimpleKnownName("The Same", "Same") + val b = SimpleKnownName("The Same", "Same") assertEquals(a, b) } @Test fun name_equals_differentSort() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) - val b = Name.Known.from("The Same", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("The Same", "Same") + val b = SimpleKnownName("The Same", null) assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_equals_intelligent_differentTokens() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(true)) - val b = Name.Known.from("Same", "Same", mockIntelligentSorting(true)) + val a = IntelligentKnownName("The Same", "Same") + val b = IntelligentKnownName("Same", "Same") assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", null, mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", null) + val b = SimpleKnownName("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { - val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(false)) - val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(false)) - val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(false)) - val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A Brain in a Bottle", null) + val b = SimpleKnownName("Acid Rain", null) + val c = SimpleKnownName("Boralis / Contrastellar", null) + val d = SimpleKnownName("Breathe In", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -365,40 +375,40 @@ class NameTest { @Test fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { - val a = Name.Known.from("15 Step", null, mockIntelligentSorting(false)) - val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(false)) - val c = Name.Known.from("1969", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("15 Step", null) + val b = SimpleKnownName("128 Harps", null) + val c = SimpleKnownName("1969", null) assertEquals(1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) } @Test fun name_compareTo_simple_withPartialSort() { - val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", "C") + val b = SimpleKnownName("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_simple_withSort() { - val a = Name.Known.from("D", "A", mockIntelligentSorting(false)) - val b = Name.Known.from("C", "B", mockIntelligentSorting(false)) + val a = SimpleKnownName("D", "A") + val b = SimpleKnownName("C", "B") assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", null, mockIntelligentSorting(true)) - val b = Name.Known.from("B", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("A", null) + val b = IntelligentKnownName("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { - val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(true)) - val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(true)) - val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(true)) - val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("A Brain in a Bottle", null) + val b = IntelligentKnownName("Acid Rain", null) + val c = IntelligentKnownName("Boralis / Contrastellar", null) + val d = IntelligentKnownName("Breathe In", null) assertEquals(1, a.compareTo(b)) assertEquals(1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -406,9 +416,9 @@ class NameTest { @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { - val a = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) - val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(true)) - val c = Name.Known.from("1969", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("15 Step", null) + val b = IntelligentKnownName("128 Harps", null) + val c = IntelligentKnownName("1969", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, b.compareTo(c)) assertEquals(-2, a.compareTo(c)) @@ -416,15 +426,28 @@ class NameTest { @Test fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", "C") + val b = SimpleKnownName("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("D", "A", mockIntelligentSorting(true)) - val b = Name.Known.from("C", "B", mockIntelligentSorting(true)) + val a = IntelligentKnownName("D", "A") + val b = IntelligentKnownName("C", "B") + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_unknown() { + val a = Name.Unknown(0) + assertEquals("?", a.thumb) + } + + @Test + fun name_compareTo_mixed() { + val a = Name.Unknown(0) + val b = IntelligentKnownName("A", null) assertEquals(-1, a.compareTo(b)) } } From 9a67a0d539e36e2a2919cec2cb20ceba946a9db2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:38:51 -0600 Subject: [PATCH 083/127] util: use timber for logging This will make testing app components a lot easier since it removes the logging dependency used in most shared objects. --- app/build.gradle | 4 +++- app/src/main/java/org/oxycblt/auxio/Auxio.kt | 5 ++++ .../auxio/home/FlipFloatingActionButton.kt | 2 +- .../oxycblt/auxio/music/metadata/TagUtil.kt | 3 +++ .../playback/system/MediaSessionComponent.kt | 3 +-- .../oxycblt/auxio/search/SearchFragment.kt | 2 +- .../java/org/oxycblt/auxio/util/LogUtil.kt | 24 ++++++------------- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 45fdff5ea..e0725339f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -142,13 +142,15 @@ dependencies { implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version" + // Logging + implementation 'com.jakewharton.timber:timber:5.0.1' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.7" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - } spotless { diff --git a/app/src/main/java/org/oxycblt/auxio/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt index df737e4c2..ebcffb5e2 100644 --- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt +++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.UISettings +import timber.log.Timber /** * A simple, rational music player for android. @@ -44,6 +45,10 @@ class Auxio : Application() { override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + // Migrate any settings that may have changed in an app update. imageSettings.migrate() playbackSettings.migrate() diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt index a03adccfd..c3cd4a82f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt @@ -51,7 +51,7 @@ constructor( // Apply the new configuration possibly set in flipTo. This should occur even if // a flip was canceled by a hide. pendingConfig?.run { - this@FlipFloatingActionButton.logD("Applying pending configuration") + logD("Applying pending configuration") setImageResource(iconRes) contentDescription = context.getString(contentDescriptionRes) setOnClickListener(clickListener) diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index c8fc5560a..f7465c73c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -24,6 +24,9 @@ import org.oxycblt.auxio.util.positiveOrNull // TODO: Remove the escaping checks, it's too expensive to do this for every single tag. +// TODO: I want to eventually be able to move a lot of this into TagWorker once I no longer have +// to deal with the cross-module dependencies of MediaStoreExtractor. + /** * Split a [String] by the given selector, automatically handling escaped characters that satisfy * the selector. 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 d3e7b8cda..1910b1a01 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 @@ -338,8 +338,7 @@ constructor( song, object : BitmapProvider.Target { override fun onCompleted(bitmap: Bitmap?) { - this@MediaSessionComponent.logD( - "Bitmap loaded, applying media session and posting notification") + logD("Bitmap loaded, applying media session and posting notification") builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) val metadata = builder.build() 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 48a436730..da74d66a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -119,7 +119,7 @@ class SearchFragment : ListFragment() { if (!launchedKeyboard) { // Auto-open the keyboard when this view is shown - this@SearchFragment.logD("Keyboard is not shown yet") + logD("Keyboard is not shown yet") showKeyboard(this) launchedKeyboard = true } diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index f7418a61e..4b1f800b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -18,27 +18,24 @@ package org.oxycblt.auxio.util -import android.util.Log import org.oxycblt.auxio.BuildConfig - -// Shortcut functions for logging. -// Yes, I know timber exists but this does what I need. +import timber.log.Timber /** * Log an object to the debug channel. Automatically handles tags. * * @param obj The object to log. */ -fun Any.logD(obj: Any?) = logD("$obj") +fun logD(obj: Any?) = logD("$obj") /** * Log a string message to the debug channel. Automatically handles tags. * * @param msg The message to log. */ -fun Any.logD(msg: String) { +fun logD(msg: String) { if (BuildConfig.DEBUG && !copyleftNotice()) { - Log.d(autoTag, msg) + Timber.d(msg) } } @@ -47,21 +44,14 @@ fun Any.logD(msg: String) { * * @param msg The message to log. */ -fun Any.logW(msg: String) = Log.w(autoTag, msg) +fun logW(msg: String) = Timber.w(msg) /** * Log a string message to the error channel. Automatically handles tags. * * @param msg The message to log. */ -fun Any.logE(msg: String) = Log.e(autoTag, msg) - -/** - * The LogCat-suitable tag for this string. Consists of the object's name, or "Anonymous Object" if - * the object does not exist. - */ -private val Any.autoTag: String - get() = "Auxio.${this::class.simpleName ?: "Anonymous Object"}" +fun logE(msg: String) = Timber.e(msg) /** * Please don't plagiarize Auxio! You are free to remove this as long as you continue to keep your @@ -71,7 +61,7 @@ private val Any.autoTag: String private fun copyleftNotice(): Boolean { if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" && BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") { - Log.d( + Timber.d( "Auxio Project", "Friendly reminder: Auxio is licensed under the " + "GPLv3 and all derivative apps must be made open source!") From 881fb58648397c38856d9223133e65992028b61b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:57:53 -0600 Subject: [PATCH 084/127] music: consider settings in equality Make it so that music items are meaningfully different when they were created under different settings. This resolves an issue where music information would not correctly update when separators or intelligent sorting would change. Resolves #546. --- CHANGELOG.md | 6 ++ .../auxio/music/device/DeviceMusicImpl.kt | 68 ++++++++++++++----- .../java/org/oxycblt/auxio/music/info/Name.kt | 10 +-- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd76d375..f3645510f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## dev + +#### What's Fixed +- Fixed app restart being required when changing intelligent sorting +or music separator settings + ## 3.2.0 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index d513fcbff..d9f381ec9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -53,8 +53,8 @@ import org.oxycblt.auxio.util.update */ class SongImpl( private val rawSong: RawSong, - nameFactory: Name.Known.Factory, - separators: Separators + private val nameFactory: Name.Known.Factory, + private val separators: Separators ) : Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. @@ -110,17 +110,6 @@ class SongImpl( override val genres: List get() = _genres - private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() - - override fun hashCode() = hashCode - - // TODO: I cant compare by raw information actually, as it also means that any settings - // configuration will be lost as well. - override fun equals(other: Any?) = - other is SongImpl && uid == other.uid && rawSong == other.rawSong - - override fun toString() = "Song(uid=$uid, name=$name)" - /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * [Album]. @@ -140,6 +129,8 @@ class SongImpl( */ val rawGenres: List + private var hashCode: Int = uid.hashCode() + init { val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds) val artistNames = separators.split(rawSong.artistNames) @@ -190,8 +181,24 @@ class SongImpl( .mapTo(mutableSetOf()) { RawGenre(it) } .toList() .ifEmpty { listOf(RawGenre()) } + + hashCode = 31 * rawSong.hashCode() + hashCode = 31 * nameFactory.hashCode() } + override fun hashCode() = hashCode + + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. + override fun equals(other: Any?) = + other is SongImpl && + uid == other.uid && + nameFactory == other.nameFactory && + separators == other.separators && + rawSong == other.rawSong + + override fun toString() = "Song(uid=$uid, name=$name)" + /** * Links this [Song] with a parent [Album]. * @@ -259,7 +266,10 @@ class SongImpl( * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Album { +class AlbumImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Album { private val rawAlbum = grouping.raw.inner override val uid = @@ -322,13 +332,20 @@ class AlbumImpl(grouping: Grouping, nameFactory: Name.Known. dateAdded = earliestDateAdded hashCode = 31 * hashCode + rawAlbum.hashCode() + hashCode = 31 * nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } override fun hashCode() = hashCode + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. override fun equals(other: Any?) = - other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs + other is AlbumImpl && + uid == other.uid && + rawAlbum == other.rawAlbum && + nameFactory == other.nameFactory && + songs == other.songs override fun toString() = "Album(uid=$uid, name=$name)" @@ -376,7 +393,10 @@ class AlbumImpl(grouping: Grouping, nameFactory: Name.Known. * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Artist { +class ArtistImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Artist { private val rawArtist = grouping.raw.inner override val uid = @@ -425,6 +445,7 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F durationMs = songs.sumOf { it.durationMs }.positiveOrNull() hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } @@ -432,10 +453,13 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F // the same UID but different songs are not equal. override fun hashCode() = hashCode + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. override fun equals(other: Any?) = other is ArtistImpl && uid == other.uid && rawArtist == other.rawArtist && + nameFactory == other.nameFactory && songs == other.songs override fun toString() = "Artist(uid=$uid, name=$name)" @@ -473,7 +497,10 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class GenreImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Genre { +class GenreImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Genre { private val rawGenre = grouping.raw.inner override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) } @@ -502,13 +529,18 @@ class GenreImpl(grouping: Grouping, nameFactory: Name.Known. durationMs = totalDuration hashCode = 31 * hashCode + rawGenre.hashCode() + hashCode = 31 * nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } override fun hashCode() = hashCode override fun equals(other: Any?) = - other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs + other is GenreImpl && + uid == other.uid && + rawGenre == other.rawGenre && + nameFactory == other.nameFactory && + songs == other.songs override fun toString() = "Genre(uid=$uid, name=$name)" diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index b0f0b029d..09f4d8035 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -95,13 +95,13 @@ sealed interface Name : Comparable { * user-defined name configuration. * * @param settings The [MusicSettings] to use. - * @return A new [Factory] instance reflecting the configuration state. + * @return A [Factory] instance reflecting the configuration state. */ fun from(settings: MusicSettings) = if (settings.intelligentSorting) { - IntelligentKnownName.Factory() + IntelligentKnownName.Factory } else { - SimpleKnownName.Factory() + SimpleKnownName.Factory } } } @@ -149,7 +149,7 @@ data class SimpleKnownName(override val raw: String, override val sort: String?) return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) } - class Factory : Name.Known.Factory { + data object Factory : Name.Known.Factory { override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) } } @@ -208,7 +208,7 @@ data class IntelligentKnownName(override val raw: String, override val sort: Str } } - class Factory : Name.Known.Factory { + data object Factory : Name.Known.Factory { override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) } From ad672ed919dbbff9819454fcae575fe531a3498c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 19:24:13 -0600 Subject: [PATCH 085/127] music: add cache repository test Add tests for the cache repository and cache data structure. --- app/build.gradle | 2 + .../java/org/oxycblt/auxio/StubTest.kt | 40 --- .../auxio/music/cache/CacheDatabase.kt | 4 +- .../auxio/music/metadata/Separators.kt | 1 + .../auxio/music/cache/CacheRepositoryTest.kt | 266 ++++++++++++++++++ 5 files changed, 271 insertions(+), 42 deletions(-) delete mode 100644 app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt diff --git a/app/build.gradle b/app/build.gradle index e0725339f..5559d0b2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,6 +149,8 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.7" + testImplementation "org.robolectric:robolectric:4.9" + testImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt deleted file mode 100644 index a0ba54a3d..000000000 --- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * StubTest.kt is part of Auxio. - * - * 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 androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -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: Make tests - @Test - fun useAppContext() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("org.oxycblt.auxio.debug", appContext.packageName) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index d28547239..2e3e8a944 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -63,9 +63,9 @@ data class CachedSong( /** @see RawSong */ var durationMs: Long, /** @see RawSong.replayGainTrackAdjustment */ - val replayGainTrackAdjustment: Float?, + val replayGainTrackAdjustment: Float? = null, /** @see RawSong.replayGainAlbumAdjustment */ - val replayGainAlbumAdjustment: Float?, + val replayGainAlbumAdjustment: Float? = null, /** @see RawSong.musicBrainzId */ var musicBrainzId: String? = null, /** @see RawSong.name */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 989a7b128..8d2740e74 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -43,6 +43,7 @@ interface Separators { const val SLASH = '/' const val PLUS = '+' const val AND = '&' + /** * Creates a new instance from the **current state** of the given [MusicSettings]'s * user-defined separator configuration. diff --git a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt new file mode 100644 index 000000000..9914dbe5f --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023 Auxio Project + * CacheRepositoryTest.kt is part of Auxio. + * + * 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.cache + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerifyAll +import io.mockk.coVerifySequence +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import java.lang.IllegalStateException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.info.Date + +class CacheRepositoryTest { + @Test + fun cache_read_noInvalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val songA = RawSong(mediaStoreId = 0, dateAdded = 1, dateModified = 2) + assertTrue(cache.populate(songA)) + assertEquals(RAW_SONG_A, songA) + + assertFalse(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertFalse(cache.invalidated) + } + + @Test + fun cache_read_invalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val nullStart = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + val nullEnd = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + assertFalse(cache.populate(nullStart)) + assertEquals(nullStart, nullEnd) + + assertTrue(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertTrue(cache.invalidated) + } + + @Test + fun cache_read_crashes() { + val dao = mockk { coEvery { readSongs() } throws IllegalStateException() } + val cacheRepository = CacheRepositoryImpl(dao) + assertEquals(null, runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + } + + @Test + fun cache_write() { + var currentlyStoredSongs = listOf() + val insertSongsArg = slot>() + val dao = + mockk { + coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() } + + coEvery { insertSongs(capture(insertSongsArg)) } answers + { + currentlyStoredSongs = insertSongsArg.captured + } + } + + val cacheRepository = CacheRepositoryImpl(dao) + + val rawSongs = listOf(RAW_SONG_A, RAW_SONG_B) + runBlocking { cacheRepository.writeCache(rawSongs) } + + val cachedSongs = listOf(CACHED_SONG_A, CACHED_SONG_B) + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(cachedSongs) + } + assertEquals(cachedSongs, currentlyStoredSongs) + } + + @Test + fun cache_write_nukeCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } throws IllegalStateException() + coEvery { insertSongs(listOf()) } just Runs + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifyAll { dao.nukeSongs() } + } + + @Test + fun cache_write_insertCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } just Runs + coEvery { insertSongs(listOf()) } throws IllegalStateException() + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(listOf()) + } + } + + private companion object { + val CACHED_SONG_A = + CachedSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val RAW_SONG_A = + RawSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val CACHED_SONG_B = + CachedSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + + val RAW_SONG_B = + RawSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + } +} From b3ef43b37e10e9bda6e717d9020780cbd589a8fa Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 22 Aug 2023 07:27:28 -0600 Subject: [PATCH 086/127] playback: start cleaning up pager impl Fix some immediate compile issues and some style nitpicks. This breaks the system a bit, but I don't think I will have enough time to debug fully for the forseeable future, so I want to get this out now. --- CHANGELOG.md | 3 + .../auxio/playback/PlaybackPanelFragment.kt | 26 ++---- .../playback/pager/PlaybackPageListener.kt | 30 ------ .../{pager => ui}/PlaybackPagerAdapter.kt | 91 +++++++------------ 4 files changed, 44 insertions(+), 106 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt rename app/src/main/java/org/oxycblt/auxio/playback/{pager => ui}/PlaybackPagerAdapter.kt (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3645510f..c2509c153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## dev +#### What's New +- Added ability to rewind/skip tracks by swiping back/forward + #### What's Fixed - Fixed app restart being required when changing intelligent sorting or music separator settings diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index c0b901c3b..9738f1de3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -39,12 +39,12 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.pager.PlaybackPageListener -import org.oxycblt.auxio.playback.pager.PlaybackPagerAdapter import org.oxycblt.auxio.playback.queue.QueueViewModel import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.ui.PlaybackPagerAdapter import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately @@ -67,7 +67,7 @@ class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, StyledSeekBar.Listener, - PlaybackPageListener { + PlaybackPagerAdapter.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private val queueModel: QueueViewModel by activityViewModels() @@ -111,27 +111,13 @@ class PlaybackPanelFragment : } // cover carousel adapter - coverAdapter = PlaybackPagerAdapter(this, viewLifecycleOwner) + coverAdapter = PlaybackPagerAdapter(this) binding.playbackCoverPager.apply { adapter = coverAdapter registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView recycler.isNestedScrollingEnabled = false } - // Set up marquee on song information, alongside click handlers that navigate to each - // respective item. - binding.playbackSong.apply { - isSelected = true - setOnClickListener { navigateToCurrentSong() } - } - binding.playbackArtist.apply { - isSelected = true - setOnClickListener { navigateToCurrentArtist() } - } - binding.playbackAlbum.apply { - isSelected = true - setOnClickListener { navigateToCurrentAlbum() } - } binding.playbackSeekBar.listener = this @@ -189,7 +175,7 @@ class PlaybackPanelFragment : } private fun updateQueue(queue: List) { - coverAdapter?.update(queue, queueModel.queueInstructions.flow.value) + coverAdapter?.update(queue, UpdateInstructions.Diff) } private fun updateQueuePosition(position: Int) { @@ -250,7 +236,7 @@ class PlaybackPanelFragment : } override fun navigateToMenu() { - binding?.playbackToolbar?.showOverflowMenu() + // TODO } private class OnCoverChangedCallback(private val viewModel: QueueViewModel) : diff --git a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt b/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt deleted file mode 100644 index a47c88494..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaybackPageListener.kt is part of Auxio. - * - * 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.pager - -interface PlaybackPageListener { - - fun navigateToCurrentArtist() - - fun navigateToCurrentAlbum() - - fun navigateToCurrentSong() - - fun navigateToMenu() -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt similarity index 56% rename from app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt index f171677cc..622dacc11 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -16,78 +16,50 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.pager +package org.oxycblt.auxio.playback.ui -import android.view.View import android.view.ViewGroup import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import kotlin.jvm.internal.Intrinsics -import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.inflater -class PlaybackPagerAdapter( - private val listener: PlaybackPageListener, - private val lifecycleOwner: LifecycleOwner -) : FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { - +/** @author Koitharu, Alexander Capehart (OxygenCobalt) */ +class PlaybackPagerAdapter(private val listener: Listener) : FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { - return CoverViewHolder.from(parent, listener).also { - lifecycleOwner.lifecycle.addObserver(it) - } + return CoverViewHolder.from(parent) } override fun onBindViewHolder(holder: CoverViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(getItem(position), listener) } override fun onViewRecycled(holder: CoverViewHolder) { holder.recycle() super.onViewRecycled(holder) } -} - -class CoverViewHolder -private constructor( - private val binding: ItemPlaybackSongBinding, - private val listener: PlaybackPageListener -) : RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver, View.OnClickListener { - - init { - binding.playbackSong.setOnClickListener(this) - binding.playbackArtist.setOnClickListener(this) - binding.playbackAlbum.setOnClickListener(this) - binding.playbackCover.setOnClickListener(this) - } - override fun onClick(v: View) { - when (v.id) { - R.id.playback_album -> listener.navigateToCurrentAlbum() - R.id.playback_artist -> listener.navigateToCurrentArtist() - R.id.playback_song -> listener.navigateToCurrentSong() - R.id.playback_cover -> listener.navigateToMenu() - } - } - - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - setSelected(true) - } - - override fun onPause(owner: LifecycleOwner) { - super.onPause(owner) - setSelected(false) + interface Listener { + fun navigateToCurrentArtist() + fun navigateToCurrentAlbum() + fun navigateToCurrentSong() + fun navigateToMenu() } +} - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - owner.lifecycle.removeObserver(this) +class CoverViewHolder private constructor(private val binding: ItemPlaybackSongBinding) : + RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver { + init { + binding.root.layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) } /** @@ -95,17 +67,27 @@ private constructor( * * @param item The new [Song] to bind. */ - fun bind(item: Song) { - binding.playbackCover.bind(item) + fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) { val context = binding.root.context - binding.playbackSong.text = item.name.resolve(context) - binding.playbackArtist.text = item.artists.resolveNames(context) - binding.playbackAlbum.text = item.album.name.resolve(context) + // binding.playbackCover.bind(item) + binding.playbackSong.apply { + text = item.name.resolve(context) + setOnClickListener { listener.navigateToCurrentSong() } + } + binding.playbackArtist.apply { + text = item.artists.resolveNames(context) + setOnClickListener { listener.navigateToCurrentArtist() } + } + binding.playbackAlbum.apply { + text = item.album.name.resolve(context) + setOnClickListener { listener.navigateToCurrentAlbum() } + } setSelected(true) } fun recycle() { // Marquee elements leak if they are not disabled when the views are destroyed. + // TODO: Move to TextView impl to avoid having to deal with lifecycle here setSelected(false) } @@ -122,11 +104,8 @@ private constructor( * @param parent The parent to inflate this instance from. * @return A new instance. */ - fun from(parent: ViewGroup, listener: PlaybackPageListener) = - CoverViewHolder( - ItemPlaybackSongBinding.inflate(parent.context.inflater, parent, false), - listener - ) + fun from(parent: ViewGroup) = + CoverViewHolder(ItemPlaybackSongBinding.inflate(parent.context.inflater)) /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = From a4e8c250a387cb2ca77890aafc055c2077c68a06 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 3 Sep 2023 21:04:46 -0600 Subject: [PATCH 087/127] music: add basic devicelibrary tests Add basic devicelibrary initialization/glue tests. This does not actually test the grouping process, as that is more involved. --- .../auxio/music/user/DeviceLibraryTest.kt | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt diff --git a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt new file mode 100644 index 000000000..cdbbc6af9 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2023 Auxio Project + * DeviceLibraryTest.kt is part of Auxio. + * + * 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.user + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.device.AlbumImpl +import org.oxycblt.auxio.music.device.ArtistImpl +import org.oxycblt.auxio.music.device.DeviceLibraryImpl +import org.oxycblt.auxio.music.device.GenreImpl +import org.oxycblt.auxio.music.device.SongImpl + +class DeviceLibraryTest { + + @Test + fun deviceLibrary_withSongs() { + val songUidA = Music.UID.auxio(MusicType.SONGS) + val songUidB = Music.UID.auxio(MusicType.SONGS) + val songA = + mockk { + every { uid } returns songUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val songB = + mockk { + every { uid } returns songUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(songA, songB), listOf(), listOf(), listOf()) + verify { + songA.finalize() + songB.finalize() + } + val foundSongA = deviceLibrary.findSong(songUidA)!! + assertEquals(songUidA, foundSongA.uid) + assertEquals(0L, foundSongA.durationMs) + val foundSongB = deviceLibrary.findSong(songUidB)!! + assertEquals(songUidB, foundSongB.uid) + assertEquals(1L, foundSongB.durationMs) + } + + @Test + fun deviceLibrary_withAlbums() { + val albumUidA = Music.UID.auxio(MusicType.ALBUMS) + val albumUidB = Music.UID.auxio(MusicType.ALBUMS) + val albumA = + mockk { + every { uid } returns albumUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val albumB = + mockk { + every { uid } returns albumUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(), listOf(albumA, albumB), listOf(), listOf()) + verify { + albumA.finalize() + albumB.finalize() + } + val foundAlbumA = deviceLibrary.findAlbum(albumUidA)!! + assertEquals(albumUidA, foundAlbumA.uid) + assertEquals(0L, foundAlbumA.durationMs) + val foundAlbumB = deviceLibrary.findAlbum(albumUidB)!! + assertEquals(albumUidB, foundAlbumB.uid) + assertEquals(1L, foundAlbumB.durationMs) + } + + @Test + fun deviceLibrary_withArtists() { + val artistUidA = Music.UID.auxio(MusicType.ARTISTS) + val artistUidB = Music.UID.auxio(MusicType.ARTISTS) + val artistA = + mockk { + every { uid } returns artistUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val artistB = + mockk { + every { uid } returns artistUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = + DeviceLibraryImpl(listOf(), listOf(), listOf(artistA, artistB), listOf()) + verify { + artistA.finalize() + artistB.finalize() + } + val foundArtistA = deviceLibrary.findArtist(artistUidA)!! + assertEquals(artistUidA, foundArtistA.uid) + assertEquals(0L, foundArtistA.durationMs) + val foundArtistB = deviceLibrary.findArtist(artistUidB)!! + assertEquals(artistUidB, foundArtistB.uid) + assertEquals(1L, foundArtistB.durationMs) + } + + @Test + fun deviceLibrary_withGenres() { + val genreUidA = Music.UID.auxio(MusicType.GENRES) + val genreUidB = Music.UID.auxio(MusicType.GENRES) + val genreA = + mockk { + every { uid } returns genreUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val genreB = + mockk { + every { uid } returns genreUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(), listOf(), listOf(), listOf(genreA, genreB)) + verify { + genreA.finalize() + genreB.finalize() + } + val foundGenreA = deviceLibrary.findGenre(genreUidA)!! + assertEquals(genreUidA, foundGenreA.uid) + assertEquals(0L, foundGenreA.durationMs) + val foundGenreB = deviceLibrary.findGenre(genreUidB)!! + assertEquals(genreUidB, foundGenreB.uid) + assertEquals(1L, foundGenreB.durationMs) + } + + @Test + fun deviceLibrary_equals() { + val songA = + mockk { + every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { finalize() } returns this + } + val songB = + mockk { + every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { finalize() } returns this + } + val album = + mockk { + every { uid } returns mockk() + every { finalize() } returns this + } + + val deviceLibraryA = DeviceLibraryImpl(listOf(songA), listOf(album), listOf(), listOf()) + val deviceLibraryB = DeviceLibraryImpl(listOf(songA), listOf(), listOf(), listOf()) + val deviceLibraryC = DeviceLibraryImpl(listOf(songB), listOf(album), listOf(), listOf()) + assertEquals(deviceLibraryA, deviceLibraryB) + assertEquals(deviceLibraryA.hashCode(), deviceLibraryA.hashCode()) + assertNotEquals(deviceLibraryA, deviceLibraryC) + assertNotEquals(deviceLibraryA.hashCode(), deviceLibraryC.hashCode()) + } +} From 23d474278a218a75f626f92bc5be080c47e2f39b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 3 Sep 2023 21:14:53 -0600 Subject: [PATCH 088/127] music: fix failing tests Use generic Collection instead of Set to prevent the new DeviceLibrary tests from failing. --- .../java/org/oxycblt/auxio/music/device/DeviceLibrary.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 739faba8c..527dcd198 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -258,10 +258,10 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // TODO: Avoid redundant data creation class DeviceLibraryImpl( - override val songs: Set, - override val albums: Set, - override val artists: Set, - override val genres: Set + override val songs: Collection, + override val albums: Collection, + override val artists: Collection, + override val genres: Collection ) : DeviceLibrary { // Use a mapping to make finding information based on it's UID much faster. private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } From f41905104f3558c1a11632485d94e729dc9dec6b Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 8 Sep 2023 21:40:16 +0200 Subject: [PATCH 089/127] Translations update from Hosted Weblate (#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (French) Currently translated at 99.6% (286 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (Romanian) Currently translated at 60.2% (173 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/ * Translated using Weblate (Hindi) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Romanian) Currently translated at 60.6% (174 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/ * Translated using Weblate (Czech) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Hindi) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (291 of 291 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Russian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (37 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/he/ * Translated using Weblate (Hebrew) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/he/ * Translated using Weblate (Croatian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Swedish) Currently translated at 89.9% (260 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 94.4% (273 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ --------- Co-authored-by: J. Lavoie Co-authored-by: Yokyrole Co-authored-by: ShareASmile Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: BMT[UA] Co-authored-by: Eric Co-authored-by: Vaclovas Intas Co-authored-by: Макар Разин Co-authored-by: FAYE Co-authored-by: Milo Ivir Co-authored-by: sköldpadda Co-authored-by: santiago046 --- app/src/main/res/values-be/strings.xml | 4 + app/src/main/res/values-cs/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-hi/strings.xml | 6 + app/src/main/res/values-hr/strings.xml | 4 + app/src/main/res/values-iw/strings.xml | 44 ++++- app/src/main/res/values-lt/strings.xml | 6 + app/src/main/res/values-pa/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 24 ++- app/src/main/res/values-ro/strings.xml | 34 ++++ app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-sv/strings.xml | 158 +++++++++++++++++- app/src/main/res/values-uk/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + .../metadata/android/he/full_description.txt | 6 +- 16 files changed, 295 insertions(+), 21 deletions(-) diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 106d70d59..0084fbd04 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -300,4 +300,8 @@ Напрамак Абярыце малюнак Абярыце + Дадаткова + Скапіравана + Інфармацыя пра памылку + Справаздача пра памылку \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9e55b50e3..3a2ef1255 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -311,4 +311,8 @@ Seřadit podle Výběr obrázku Výběr + Další + Informace o chybě + Zkopírovat + Nahlásit \ 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 0712780dd..a4a109704 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -306,4 +306,8 @@ Dirección Selección de imágenes Selección + Más + Información sobre el error + Copiado + Informar \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b063a1730..119710e5e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -300,4 +300,8 @@ Chanson Voir Jouer la chanson par elle-même + Image de sélection + Trier par + Direction + Sélection \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 97ffbd5ca..0384924fd 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -299,4 +299,10 @@ इसी गीत को चलाएं दिशा के अनुसार क्रमबद्ध करें + संग्रह + चयन छवि + त्रुटि की जानकारी + रिपोर्ट करें + कापी किया गया + और \ 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 fecd2b6f2..22bab4d29 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -297,4 +297,8 @@ Smjer Slika odabira Odabir + Više + Podaci greške + Prijavi grešku + Kopirano \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index e85f5a07e..e864933bf 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -176,12 +176,12 @@ לא ניתן לשמור את המצב ‏ Auxio צריך הרשאות על מנת לקרוא את ספריית המוזיקה שלך פתיחת התור - סך הכל משך: %s + משך כולל: %s רשימת השמעה %d אומנים טעונים: %d שירים טעונים: %d אלבומים טעונים: %d - סוגות טעונות: %d + ז\'אנרים טעונים: %d המצב נוקה ספריה שמירת מצב הנגינה הנוכחי כעת @@ -197,15 +197,15 @@ תמונת אומן עבור %s יצירת תמונה עבור %s אומן לא ידוע - סוגה לא ידועה + ז\'אנר לא ידוע אין תאריך אין רצועה - אך מוזיקה אינה מתנגנת + מוזיקה לא מתנגנת כחול כחול עמוק אפור דינמי - המוזיקה שלך בטעינה (‎%1$d/%2$d)… + המוזיקה שלך בטעינה... (‎%1$d/%2$d) דיסק %d ניהול המקומות שמהם תיטען מוזיקה אין שירים @@ -242,7 +242,7 @@ תמונת רשימת השמעה עבור %s אדום ירוק - נתיב הורה + נתיב ראשי לא ניתן לשחזר את המצב רצועה %d יצירת רשימת השמעה חדשה @@ -257,7 +257,7 @@ אין דיסק ירוק עמוק צהוב - מחיקת %s\? פעולה זו לא ניתן לביטול. + למחוק את %s\? פעולה זו לא ניתן לביטול. שיר מיון חכם הצגה @@ -271,6 +271,34 @@ מוזיקה תיטען רק מהתיקיות שנוספו. מופיע~ה ב- ניגון השיר בלבד - אזהרה: שינוי המגבר לערך חיובי גבוה עלול לגרום לשיאים בחלק מרצועות האודיו + אזהרה: שינוי המגבר לערך חיובי גבוה עלול לגרום לעיוות (דיסטורשן) בחלק מרצועות האודיו. שחזור מצב נגינה + אינדיגו + אודיו MPEG-1 + אודיו MPEG-4 + אודיו Ogg + ציאן + טורקיז + חום + %d נבחרו + התמדה + עוד + בחירה + מידע על השגיאה + דיווח + תמונה נבחרת + קודק אודיו חופשי ללא איבוד נתונים (FLAC) + סגול + סגול עמוק + +%.1f דציבלים (dB) + -%.1f דציבלים (dB) + %d הרץ (Hz) + %d קילוביטים לשנייה (kbps) + מועתק + שחזור מצב הנגינה שנשמר קודם (אם קיים) + אודיו Matroska + קידוד אודיו מתקדם (AAC) + %1$s, %2$s + ליים + %s נערך \ 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 cfcbccdcf..467ace0d2 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -297,4 +297,10 @@ Groti dainą pačią Rūšiuoti pagal Kryptis + Pasirinkimo vaizdas + Pasirinkimas + Klaidos informacija + Nukopijuota + Daugiau + Pranešti \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 175c77cdb..7ccfca42d 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -292,4 +292,10 @@ ਇਸੇ ਗੀਤ ਨੂੰ ਚਲਾਓ ਸੌਰਟ ਕਰੋ ਦਿਸ਼ਾ + ਚੋਣ + ਚੋਣ ਚਿੱਤਰ + ਹੋਰ + ਤਰੁੱਟੀ ਦੀ ਜਾਣਕਾਰੀ + ਕਾਪੀ ਕੀਤਾ ਗਿਆ + ਰਿਪੋਰਟ ਕਰੋ \ 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 ef6191ce3..5cf5f433d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -11,7 +11,7 @@ Pesquisar Filtro Tudo - Classificar + Organizar Reproduzir Aleatório Tocando agora @@ -273,4 +273,26 @@ Descendente Ignorar artigos ao classificar Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Playlists + Playlist %d + Playlist + Playlist criada + Mais + Apagar + Copiado + Adicionar à playlist + Compartilhar + Editar + Renomear + Adicionada à playlist + Editando %s + Organizar por + Música + Apagar playlist\? + Criar uma nova playlist + Playlist deletada + Nova playlist + Playlist renomeada + Renomear playlist + Aparece em \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4351fd565..e892e2739 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -157,4 +157,38 @@ Redă din album În timpul redării de la detaliile articolului Comportament + Listă de redare nouă + Ignoră fișiere audio care nu sunt muzică, precum podcasturi + Plus (+) + Melodie + Listă de redare creată + Șterge + Ascunde colaboratori + Oprit + Taie toate coperțile de album la raportul de aspect 1:1 + Sortare inteligentă + Redenumiți lista da redare + Șterge lista de redare\? + Redenumiți + Controlează cum muzica și imaginile sunt încărcate + Sortează după + Sortare corectă pentru nume care incep cu numere sau cuvinte precum \"the\" (funcționează cel mai bine cu melodii în limba engleză) + Forțează coperți de album pătrate + Rapid + Calitate mare + Punct și virgulă (;) + Editează + Exclude non-muzică + Adaugat către lista de redare + Reîncărcare automată + Virgulă (,) + Reîncărcați biblioteca de muzică oricând se schimbă (Necesită notificare persistentă) + Imagini + Apare în + Partajați + Listă de redare redenumită + Listă de redare ștearsă + Coperți de album + Adaugă către listă de redare + Direcție \ 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 6e93fb54f..2dc979122 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -309,4 +309,8 @@ Направление Выберите Выберите изображение + Дополнительно + Информация об ошибке + Отчёт об ошибке + Скопировано \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 60f9e5c97..16af4e8f1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -3,7 +3,7 @@ Försök igen Musik laddar Laddar musik - Alla låtar + Alla spår Album Albumet Remix-album @@ -23,7 +23,7 @@ Remixar Framträder på Konstnär - Konstnär + Konstnärer Genrer Spellista Spellistor @@ -68,11 +68,11 @@ Licenser Visa och kontrollera musikuppspelning Laddar ditt musikbibliotek… - Övervakning ditt musikbibliotek för ändringar… - Tillagd till kö + Overvåker ditt musikbibliotek för ändringar… + Tillagd i kö Spellista skapade Tillagd till spellista - Sök ditt musikbibliotek… + Sök i ditt musikbibliotek… Inställningar Utseende Ändra tema och färger på appen @@ -83,7 +83,7 @@ Bevilja En enkel, rationell musikspelare för Android. Övervakar musikbiblioteket - Låtar + Spår Live-album Ta bort Live-sammanställning @@ -107,7 +107,7 @@ Tillstånd sparat Version Statistik över beroende - Bytt namn av spellista + Byt namn av spellista Spellista tog bort Utvecklad av Alexander Capeheart Tema @@ -125,7 +125,7 @@ När spelar från artikeluppgifter Spela från genre Komma ihåg blandningsstatus - Behåll blandning på när spelar en ny låt + Behåll blandning på när en ny låt spelas Kontent Kontrollera hur musik och bilar laddas Musik @@ -151,4 +151,146 @@ Konfigurera tecken som separerar flera värden i taggar Advarsel: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\). Anpassa UI-kontroller och beteende + Av + Hörlurar-autouppspelning + Pausa när en låt upprepas + Musik laddas inte från mapparna som ni lägger till. + Öppna kö + Dynamisk + %d konstnärer som laddats + + %d konstnär + %d konstnärer + + Bildar + Ljud + Konfigurera ljud- och uppspelningsbeteende + Spola tillbaka innan spår hoppar tillbaka + ReplayGain-strategi + Rensa det tidigare sparade uppspelningsläget om det finns + Återställ uppspelningsläge + -%.1f dB + Radera %s\? Detta kan inte ångras. + Endast visa artister som är direkt krediterade på ett album (funkar bäst på välmärkta bibliotek) + Albumomslag + Snabbt + Bibliotek + Inkludera + Uppdatera musik + Ladda musikbiblioteket om och använd cachad taggar när det är möjligt + Uthållighet + Rensa uppspelningsläge + Återställ det tidigare lagrade uppspelningsläget om det finns + Misslyckades att spara uppspelningsläget + Blanda alla spår + Rensa sökfrågan + Radera mappen + Genrebild för %s + Spellistabild för %s + MPEG-1-ljud + MPEG-4-ljud + OGG-ljud + Matroska-ljud + Blå + Mörkblå + Cyanblå + Blågrön + Grön + Mörkgrön + Limegrön + Gul + Grå + %1$s, %2$s + Redigerar %s + Uppspelning + Orange + Brun + Alltid börja uppspelning när hörlurar kopplas till (kanske inte fungerar på alla enheter) + Pausa vid upprepa + ReplayGain förförstärkare + Justering utan taggar + Musikmappar + Varning: Om man ändrar förförstärkaren till ett högt positivt värde kan det leda till toppning på vissa ljudspår. + Hantera var musik bör laddas in från + Mappar + Modus + Utesluta + Musik laddas endast från mapparna som ni lägger till. + Spara det aktuella uppspelningsläget + Skanna musik om + Rensa tagbiblioteket och ladda komplett om musikbiblioteket (långsammare, men mer komplett) + Ingen musik på gång + Laddning av musik misslyckades + Auxio behöver tillstånd för att läsa ditt musikbibliotek + Ingen app på gång som kan hantera denna uppgift + Denna mapp stöds inte + Misslyckades att återställa uppspelningsläget + Spår %d + Spela eller pausa + Flytta detta spår + Okänd konstnär + Okänd genre + Avancerad audio-koding (AAC) + %d utvalda + Spellista %d + +%.1f dB + + %d spår + %d spår + + + %d album + %d album + + %d spår som laddats + Total längd: %s + Kopierade + Urval + Felinformation + Rapportera + Ingen datum + Ingen disk + Inget spår + Inga spår + Lilla + %d kbps + %d Hz + %d album som laddats + %d genrer som laddats + Spela upp låten själv + Hög kvalitet + Tvinga fyrkantiga skivomslag + Beskär alla albumomslag till en 1:1 sidförhållande + Spola tillbaka innan att hoppa till föregående låt + Justering med taggar + Inga mappar + Misslyckades att rensa uppspelningsläget + Skapa en ny spellista + Stoppa uppspelning + Radera detta spår + Auxio-ikon + Flytta denna flik + Albumomslag + Urvalbild + Mörklila + Indigo + Disk %d + Spara uppspelningsläge + Hoppa till nästa spår + Hoppa till sista spår + Ändra upprepningsläge + Slå på eller av blandningen + Albumomslag för %s + Konstnärbild för %s + Ingen musik spelas + Fritt tapsfritt ljudkodek (FLAC) + Rosa + Laddar ditt musikbibliotek… (%1$d/%2$d) + Ampersand (&) + ReplayGain + Föredra spår + Föredra album + Föredra album om ett album spelar + Förförstarkning användas för befintliga justeringar vid uppspelning + Röd \ 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 ddeb997f0..f4b12a5aa 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -306,4 +306,8 @@ Напрямок Вибрати Вибрати зображення + Докладніше + Інформація про помилку + Скопійовано + Звіт \ 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 c5e17d0c4..51381f724 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -300,4 +300,8 @@ 说明 选择 选择图片 + 报告 + 更多 + 已复制 + 错误信息 \ No newline at end of file diff --git a/fastlane/metadata/android/he/full_description.txt b/fastlane/metadata/android/he/full_description.txt index 588fec696..1cadc6eb3 100644 --- a/fastlane/metadata/android/he/full_description.txt +++ b/fastlane/metadata/android/he/full_description.txt @@ -6,10 +6,9 @@ - ממשק משתמש מהיר שנגזר מהנחיות Material Design האחרונות ביותר - חוויית משתמש שמתעדפת נוחות שימוש על פני מקרי קיצון - התנהגות מותאמת אישית -- תמיכה במספרי דיסק, אומנים מרובים, סוגי שחרור, +- תמיכה במספרי דיסק, אומנים מרובים, סוגי שחרור, תאריכים מדוייקים/מקוריים, תגיות מיון, ועוד - מערכת אומנים מתקדמת שמאחדת אומנים ואומני אלבום - - ניהול תיקיות מודע לכרטיסי SD - פונקציונליות פלייליסטים אמינה - התמדה במצב ההשמעה @@ -21,5 +20,4 @@ - ניגון אוטומטי באוזניות - ווידג'טים אלגנטיים שמתאימים את עצמם לגודלם אוטומטית - פרטי לגמרי ולא מקוון - -- ללא עטיפות אלבום מעוגלות (אלא אם את.ה מעוניינ.ת בהם. אחרת אפשר.) +- ללא עטיפות אלבום מעוגלות (אלא אם את.ה מעוניינ.ת בהם. אז זה אפשרי.) From 23c538e7aaa5481164089c20e0a8fccf67f153d9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Sep 2023 11:16:23 -0600 Subject: [PATCH 090/127] Make README more professional Yeah, this gets rid of a little of the character, but it these kinds of quips a little immature and might turn off recruiters. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 8b16f36ed..55c255e7d 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,6 @@ Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.** -I primarily built Auxio for myself, but you can use it too, I guess. - **The default branch is the development version of the repository. For a stable version, see the master branch.** ## Screenshots @@ -60,7 +58,7 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers (Unless you want them. Then you can.) +- No rounded album covers ## Permissions From b84e3de3e007ee214b5a1450fd69d408019d5db1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Sep 2023 11:18:35 -0600 Subject: [PATCH 091/127] info: clarify readme portions --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 55c255e7d..4c867895b 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,12 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers +- No rounded album covers (by default) ## Permissions -- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files -- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background +- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files +- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background ## Building From 73ef51c8be04c71d507ba6799f4572a88a90965a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 20:04:34 -0600 Subject: [PATCH 092/127] build: update deps --- app/build.gradle | 10 +++++----- build.gradle | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5559d0b2b..a17b46060 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,9 +85,9 @@ dependencies { // --- SUPPORT --- // General - implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.activity:activity-ktx:1.7.2" + implementation "androidx.activity:activity-ktx:1.8.0" implementation "androidx.fragment:fragment-ktx:1.6.1" // Components @@ -100,7 +100,7 @@ dependencies { implementation "androidx.viewpager2:viewpager2:1.0.0" // Lifecycle - def lifecycle_version = "2.6.1" + def lifecycle_version = "2.6.2" implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" @@ -117,7 +117,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-alpha03' + def room_version = '2.6.0-rc01' implementation "androidx.room:room-runtime:$room_version" ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -134,7 +134,7 @@ dependencies { // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.10.0-alpha06" + implementation "com.google.android.material:material:1.10.0" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" diff --git a/build.gradle b/build.gradle index 8713d9839..3bf27b378 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - kotlin_version = '1.9.0' + kotlin_version = '1.9.10' navigation_version = "2.5.3" hilt_version = '2.47' } @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version "8.1.0" apply false + id "com.android.application" version '8.1.2' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.0-1.0.12' apply false From 4eacb65aff74f5dd39efccbe028bc4913ccebc86 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 20:28:29 -0600 Subject: [PATCH 093/127] music: fix incorrect hashing Forgot a + hashCode in like half of the hashing statements, ended up causing incorrect image cache hits. --- .../org/oxycblt/auxio/music/device/DeviceMusicImpl.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index d9f381ec9..500711d42 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -182,8 +182,8 @@ class SongImpl( .toList() .ifEmpty { listOf(RawGenre()) } - hashCode = 31 * rawSong.hashCode() - hashCode = 31 * nameFactory.hashCode() + hashCode = 31 * hashCode + rawSong.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() } override fun hashCode() = hashCode @@ -332,7 +332,7 @@ class AlbumImpl( dateAdded = earliestDateAdded hashCode = 31 * hashCode + rawAlbum.hashCode() - hashCode = 31 * nameFactory.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } @@ -529,7 +529,7 @@ class GenreImpl( durationMs = totalDuration hashCode = 31 * hashCode + rawGenre.hashCode() - hashCode = 31 * nameFactory.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } From d51da1b4bf7ad2658f1492355de1a5a4faf09831 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 20:30:54 -0600 Subject: [PATCH 094/127] playback: export playback reciever Completely misunderstood how this would affect the widget/notification on Android 14. Apparently it just blocks all intents, even if they are tangentially from the app. Resolves #598. --- .../java/org/oxycblt/auxio/playback/system/PlaybackService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c131238e0..15c8cf0eb 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 @@ -166,7 +166,7 @@ class PlaybackService : } ContextCompat.registerReceiver( - this, systemReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) + this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) logD("Service created") } From 2fe0f3e7d8f30f1e2301627be9e3cbb56f4cfb7d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 20:44:57 -0600 Subject: [PATCH 095/127] playback: partially build back pager impl --- .../oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt index 622dacc11..d0f176231 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -20,19 +20,18 @@ package org.oxycblt.auxio.playback.ui import android.view.ViewGroup import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import kotlin.jvm.internal.Intrinsics import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding import org.oxycblt.auxio.list.adapter.FlexibleListAdapter -import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.inflater /** @author Koitharu, Alexander Capehart (OxygenCobalt) */ -class PlaybackPagerAdapter(private val listener: Listener) : FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { +class PlaybackPagerAdapter(private val listener: Listener) : + FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { return CoverViewHolder.from(parent) } @@ -48,8 +47,11 @@ class PlaybackPagerAdapter(private val listener: Listener) : FlexibleListAdapter interface Listener { fun navigateToCurrentArtist() + fun navigateToCurrentAlbum() + fun navigateToCurrentSong() + fun navigateToMenu() } } @@ -69,6 +71,7 @@ class CoverViewHolder private constructor(private val binding: ItemPlaybackSongB */ fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) { val context = binding.root.context + binding.playbackCover.bind(item) // binding.playbackCover.bind(item) binding.playbackSong.apply { text = item.name.resolve(context) From b19b6665bb5a827d1b643c0e40e30bce3f7f8ac0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 20:51:19 -0600 Subject: [PATCH 096/127] music: accept native m4a multi-value tags M4A has it's own multi-value spec that works similarly to vorbis, where they just repeat the atom several times with multiple values. Since M4A atoms are remapped to ID3v2 frames, this more or less requires us to tolerate duplicate ID3v2 frames as well, which is frustratingly a spec violation. It solves the problem though Resolves #558. --- CHANGELOG.md | 4 +++ .../oxycblt/auxio/music/metadata/TextTags.kt | 13 +++++---- .../auxio/music/metadata/TextTagsTest.kt | 28 ++++++++++++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2509c153..8d72ef4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,13 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward +#### What's Improved +- Added support for native M4A multi-value tags based on duplicate atoms + #### What's Fixed - Fixed app restart being required when changing intelligent sorting or music separator settings +- Fixed widget/notification actions not working on Android 14 ## 3.2.0 diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index a3d916b69..737ee6f8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -22,17 +22,16 @@ import androidx.media3.common.Metadata import androidx.media3.extractor.metadata.id3.InternalFrame import androidx.media3.extractor.metadata.id3.TextInformationFrame import androidx.media3.extractor.metadata.vorbis.VorbisComment +import org.oxycblt.auxio.util.logD /** * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. * * @param metadata The [Metadata] to wrap. * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Merge with TagWorker */ class TextTags(metadata: Metadata) { - private val _id3v2 = mutableMapOf>() + private val _id3v2 = mutableMapOf>() /** The ID3v2 text identification frames found in the file. Can have more than one value. */ val id3v2: Map> get() = _id3v2 @@ -53,7 +52,11 @@ class TextTags(metadata: Metadata) { ?: tag.id.sanitize() val values = tag.values.map { it.sanitize() }.correctWhitespace() if (values.isNotEmpty()) { - _id3v2[id] = values + // Normally, duplicate ID3v2 frames are forbidden. But since MP4 atoms, + // which can also have duplicates, are mapped to ID3v2 frames by ExoPlayer, + // we must drop this invariant and gracefully treat duplicates as if they + // are another way of specfiying multi-value tags. + _id3v2.getOrPut(id) { mutableListOf() }.addAll(values) } } is InternalFrame -> { @@ -62,7 +65,7 @@ class TextTags(metadata: Metadata) { val id = "TXXX:${tag.description.sanitize().lowercase()}" val value = tag.text if (value.isNotEmpty()) { - _id3v2[id] = listOf(value) + _id3v2.getOrPut(id) { mutableListOf() }.add(value) } } is VorbisComment -> { diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index 73c5b926d..9966c16e9 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -56,7 +56,20 @@ class TextTagsTest { } @Test - fun textTags_combined() { + fun textTags_mp4() { + val textTags = TextTags(MP4_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"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) + } + + @Test + fun textTags_id3v2_vorbis_combined() { val textTags = TextTags(VORBIS_METADATA.copyWithAppendedEntriesFrom(ID3V2_METADATA)) assertEquals(listOf("Wheel"), textTags.vorbis["title"]) assertEquals(listOf("Paraglow"), textTags.vorbis["album"]) @@ -95,6 +108,19 @@ class TextTagsTest { TextInformationFrame("TPE1", null, listOf("Parannoul", "Asian Glow")), TextInformationFrame("TDRC", null, listOf("2022")), TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")), + TextInformationFrame("TXXX", "replaygain_track_gain", listOf("+2 dB")), + ApicFrame("", "", 0, byteArrayOf())) + + // MP4 atoms are mapped to ID3v2 text information frames by ExoPlayer, but can + // duplicate frames and have ---- mapped to InternalFrame. + private val MP4_METADATA = + Metadata( + TextInformationFrame("TIT2", null, listOf("Wheel")), + TextInformationFrame("TALB", null, listOf("Paraglow")), + TextInformationFrame("TPE1", null, listOf("Parannoul")), + TextInformationFrame("TPE1", null, listOf("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 243fb73f94005ebc0e8fdc554ea35c55641ec56a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 21:03:42 -0600 Subject: [PATCH 097/127] music: add context to malformed errors Makes debugging easier. --- .../auxio/music/device/DeviceMusicImpl.kt | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 500711d42..91a4e1702 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -75,28 +75,41 @@ class SongImpl( } override val name = nameFactory.parse( - requireNotNull(rawSong.name) { "Invalid raw: No title" }, rawSong.sortName) + requireNotNull(rawSong.name) { "Invalid raw ${rawSong.fileName}: No title" }, + rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } override val date = rawSong.date - override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() + override val uri = + requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" } + .toAudioUri() override val path = Path( - name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" }, - parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" }) + name = + requireNotNull(rawSong.fileName) { + "Invalid raw ${rawSong.fileName}: No display name" + }, + parent = + requireNotNull(rawSong.directory) { + "Invalid raw ${rawSong.fileName}: No parent directory" + }) override val mimeType = MimeType( fromExtension = - requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" }, + requireNotNull(rawSong.extensionMimeType) { + "Invalid raw ${rawSong.fileName}: No mime type" + }, fromFormat = null) - override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } - override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } + override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.fileName}: No size" } + override val durationMs = + requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.fileName}: No duration" } override val replayGainAdjustment = ReplayGainAdjustment( track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) - override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } + override val dateAdded = + requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.fileName}: No date added" } private var _album: AlbumImpl? = null override val album: Album @@ -161,9 +174,14 @@ class SongImpl( rawAlbum = RawAlbum( mediaStoreId = - requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, + requireNotNull(rawSong.albumMediaStoreId) { + "Invalid raw ${rawSong.fileName}: No album id" + }, musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), - name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, + name = + requireNotNull(rawSong.albumName) { + "Invalid raw ${rawSong.fileName}: No album name" + }, sortName = rawSong.albumSortName, releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)), rawArtists = @@ -232,10 +250,12 @@ class SongImpl( * @return This instance upcasted to [Song]. */ fun finalize(): Song { - checkNotNull(_album) { "Malformed song: No album" } + checkNotNull(_album) { "Malformed song ${path.name}: No album" } - check(_artists.isNotEmpty()) { "Malformed song: No artists" } - check(_artists.size == rawArtists.size) { "Malformed song: Artist grouping mismatch" } + check(_artists.isNotEmpty()) { "Malformed song ${path.name}: No artists" } + check(_artists.size == rawArtists.size) { + "Malformed song ${path.name}: Artist grouping mismatch" + } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. @@ -245,8 +265,10 @@ class SongImpl( _artists[i] = other } - check(_genres.isNotEmpty()) { "Malformed song: No genres" } - check(_genres.size == rawGenres.size) { "Malformed song: Genre grouping mismatch" } + check(_genres.isNotEmpty()) { "Malformed song ${path.name}: No genres" } + check(_genres.size == rawGenres.size) { + "Malformed song ${path.name}: Genre grouping mismatch" + } for (i in _genres.indices) { // Non-destructively reorder the linked genres so that they align with // the genre ordering within the song metadata. @@ -371,9 +393,11 @@ class AlbumImpl( * @return This instance upcasted to [Album]. */ fun finalize(): Album { - check(songs.isNotEmpty()) { "Malformed album: Empty" } - check(_artists.isNotEmpty()) { "Malformed album: No artists" } - check(_artists.size == rawArtists.size) { "Malformed album: Artist grouping mismatch" } + check(songs.isNotEmpty()) { "Malformed album $name: Empty" } + check(_artists.isNotEmpty()) { "Malformed album $name: No artists" } + check(_artists.size == rawArtists.size) { + "Malformed album $name: Artist grouping mismatch" + } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. @@ -434,7 +458,7 @@ class ArtistImpl( music.link(this) albumMap[music] = true } - else -> error("Unexpected input music ${music::class.simpleName}") + else -> error("Unexpected input music $music in $name ${music::class.simpleName}") } } @@ -482,7 +506,7 @@ class ArtistImpl( * @return This instance upcasted to [Artist]. */ fun finalize(): Artist { - check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" } + check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist $name: Empty" } genres = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) @@ -562,7 +586,7 @@ class GenreImpl( * @return This instance upcasted to [Genre]. */ fun finalize(): Genre { - check(songs.isNotEmpty()) { "Malformed genre: Empty" } + check(songs.isNotEmpty()) { "Malformed genre $name: Empty" } return this } } From 94e2c3c3e4b7c2b2cbd7ef0c2ee58f32414b419d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 21:33:53 -0600 Subject: [PATCH 098/127] playback: further pager cleanup - Use Replace instead of Diff for now since that avoids the scroll state freaking out. In practice I'll likely need to radically refactor this system (AGAIN...) to make the queue updates 100% fine-grained, even during shuffling. - Remove the behaivor of staying paused on the next track. That's covered by #568. --- .../org/oxycblt/auxio/playback/PlaybackPanelFragment.kt | 8 ++++---- .../org/oxycblt/auxio/playback/queue/QueueFragment.kt | 2 +- .../org/oxycblt/auxio/playback/queue/QueueViewModel.kt | 4 ++-- .../oxycblt/auxio/playback/state/PlaybackStateManager.kt | 7 +++---- .../auxio/playback/system/MediaSessionComponent.kt | 2 +- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 9738f1de3..53cbbe7fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -175,7 +175,7 @@ class PlaybackPanelFragment : } private fun updateQueue(queue: List) { - coverAdapter?.update(queue, UpdateInstructions.Diff) + coverAdapter?.update(queue, UpdateInstructions.Replace(0)) } private fun updateQueuePosition(position: Int) { @@ -239,7 +239,7 @@ class PlaybackPanelFragment : // TODO } - private class OnCoverChangedCallback(private val viewModel: QueueViewModel) : + private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : OnPageChangeCallback() { private var targetPosition = RecyclerView.NO_POSITION @@ -253,8 +253,8 @@ class PlaybackPanelFragment : super.onPageScrollStateChanged(state) if (state == ViewPager2.SCROLL_STATE_IDLE && targetPosition != RecyclerView.NO_POSITION && - targetPosition != viewModel.index.value) { - viewModel.goto(targetPosition, playIfPaused = false) + targetPosition != queueViewModel.index.value) { + queueViewModel.goto(targetPosition) } } } 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 ca3a924c4..2db007971 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 @@ -88,7 +88,7 @@ class QueueFragment : ViewBindingFragment(), EditClickList } override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { - queueModel.goto(viewHolder.bindingAdapterPosition, playIfPaused = true) + queueModel.goto(viewHolder.bindingAdapterPosition) } override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { 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 12a51bbf7..acf47a421 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 @@ -108,12 +108,12 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt * range. * @param playIfPaused Start playing after switching even if it currently is paused */ - fun goto(adapterIndex: Int, playIfPaused: Boolean) { + fun goto(adapterIndex: Int) { if (adapterIndex !in queue.value.indices) { return } logD("Going to position $adapterIndex in queue") - playbackManager.goto(adapterIndex, playIfPaused || playbackManager.playerState.isPlaying) + playbackManager.goto(adapterIndex) } /** 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 7071f5111..870d7a84e 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 @@ -120,9 +120,8 @@ interface PlaybackStateManager { * Play a [Song] at the given position in the queue. * * @param index The position of the [Song] in the queue to start playing. - * @param play Whether to start playing after switching to target index */ - fun goto(index: Int, play: Boolean) + fun goto(index: Int) /** * Add [Song]s to the top of the queue. @@ -430,12 +429,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun goto(index: Int, play: Boolean) { + override fun goto(index: Int) { val internalPlayer = internalPlayer ?: return if (queue.goto(index)) { logD("Moving to $index") notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, play) + internalPlayer.loadSong(queue.currentSong, true) } else { logW("$index was not in bounds, could not move to it") } 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 efb266eab..1910b1a01 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 @@ -256,7 +256,7 @@ constructor( } override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt(), true) + playbackManager.goto(id.toInt()) } override fun onCustomAction(action: String?, extras: Bundle?) { From f4518eb70f0ae50ad5eda2f0fce071bb37b46a5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 21:36:41 -0600 Subject: [PATCH 099/127] build: update deps Forgot to update ksp. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3bf27b378..e2f1717dc 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ plugins { id "com.android.application" version '8.1.2' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false - id "com.google.devtools.ksp" version '1.9.0-1.0.12' apply false + id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false id "com.diffplug.spotless" version "6.20.0" apply false } From 48b0b11e88818f3fa21970e5c0faa0fd11b8c4e8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 16 Oct 2023 21:38:14 -0600 Subject: [PATCH 100/127] all: random cleanup --- CHANGELOG.md | 4 ++++ app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 4 ++++ app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 3 +-- .../main/java/org/oxycblt/auxio/music/metadata/TextTags.kt | 1 - 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d72ef4c2..3a1707f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,10 @@ aspect ratio setting #### What's Fixed - Playlist detail view now respects playback settings + +#### Dev/Meta +- Revamped navigation backend + ## 3.1.4 #### What's Fixed diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 297bad95c..ed1b47c7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -204,6 +204,10 @@ class MainFragment : } override fun onPreDraw(): Boolean { + // TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom + // sheets continually getting stuck. I need something with even more frequent updates, + // or otherwise bottom sheets get stuck. + // We overload CoordinatorLayout far too much to rely on any of it's typical // listener functionality. Just update all transitions before every draw. Should // probably be cheap enough. diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 662eb49c5..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -223,8 +223,7 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, private val deviceLibraryFactory: DeviceLibrary.Factory, - private val userLibraryFactory: UserLibrary.Factory, - private val musicSettings: MusicSettings + private val userLibraryFactory: UserLibrary.Factory ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index 737ee6f8c..7e8c87391 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -22,7 +22,6 @@ import androidx.media3.common.Metadata import androidx.media3.extractor.metadata.id3.InternalFrame import androidx.media3.extractor.metadata.id3.TextInformationFrame import androidx.media3.extractor.metadata.vorbis.VorbisComment -import org.oxycblt.auxio.util.logD /** * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. From be97e110a6caae1219db3250b34cea9d0de6302d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 4 Nov 2023 03:50:21 +0100 Subject: [PATCH 101/127] Translations update from Hosted Weblate (#569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Hungarian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hu/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 94.8% (274 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (Indonesian) Currently translated at 68.5% (198 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/id/ * Translated using Weblate (Korean) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Korean) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Finnish) Currently translated at 91.6% (265 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Arabic (ar_IQ)) Currently translated at 59.5% (172 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar_IQ/ * Translated using Weblate (German) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 94.8% (274 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/ * Translated using Weblate (French) Currently translated at 99.6% (288 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/ * Translated using Weblate (French) Currently translated at 100.0% (37 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fr/ * Added translation using Weblate (Slovenian) * Translated using Weblate (Slovenian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sl/ * Translated using Weblate (Slovenian) Currently translated at 100.0% (37 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/sl/ * Added translation using Weblate (Portuguese) * Translated using Weblate (Portuguese (Portugal)) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/ * Translated using Weblate (Portuguese (Portugal)) Currently translated at 97.2% (36 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pt_PT/ * Translated using Weblate (Turkish) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/ * Translated using Weblate (Turkish) Currently translated at 100.0% (37 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/tr/ * Translated using Weblate (Korean) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Korean) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Portuguese (Portugal)) Currently translated at 99.3% (287 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/ * Translated using Weblate (Portuguese (Portugal)) Currently translated at 99.6% (288 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Portuguese (Portugal)) Currently translated at 100.0% (37 of 37 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pt_PT/ --------- Co-authored-by: Tibor Botfai (gidano) Co-authored-by: santiago046 Co-authored-by: Reza Almanda Co-authored-by: Yurical Co-authored-by: Jiri Grönroos Co-authored-by: ShareASmile Co-authored-by: Hamzoz Co-authored-by: qwerty287 Co-authored-by: cwpute Co-authored-by: K_Lar Co-authored-by: pringless Co-authored-by: Bai Co-authored-by: Vaclovas Intas --- app/src/main/res/values-ar-rIQ/strings.xml | 27 +- app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-fi/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 12 +- app/src/main/res/values-hu/strings.xml | 4 + app/src/main/res/values-in/strings.xml | 22 ++ app/src/main/res/values-ko/strings.xml | 148 +++++---- app/src/main/res/values-lt/strings.xml | 34 +- app/src/main/res/values-pa/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 5 +- app/src/main/res/values-pt-rPT/strings.xml | 131 +++++--- app/src/main/res/values-pt/strings.xml | 2 + app/src/main/res/values-sl/strings.xml | 307 ++++++++++++++++++ app/src/main/res/values-tr/strings.xml | 32 +- .../android/fr-FR/full_description.txt | 23 ++ .../android/pt-PT/full_description.txt | 21 ++ .../android/pt-PT/short_description.txt | 1 + .../metadata/android/sl/full_description.txt | 22 ++ .../metadata/android/sl/short_description.txt | 1 + .../metadata/android/tr/full_description.txt | 18 +- 20 files changed, 666 insertions(+), 157 deletions(-) create mode 100644 app/src/main/res/values-pt/strings.xml create mode 100644 app/src/main/res/values-sl/strings.xml create mode 100644 fastlane/metadata/android/fr-FR/full_description.txt create mode 100644 fastlane/metadata/android/pt-PT/full_description.txt create mode 100644 fastlane/metadata/android/pt-PT/short_description.txt create mode 100644 fastlane/metadata/android/sl/full_description.txt create mode 100644 fastlane/metadata/android/sl/short_description.txt diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 97794174f..011240291 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -1,7 +1,7 @@ - مشغل موسيقى بسيط ومعقول للأندرويد + مشغل موسيقى بسيط ومعقول للأندرويد. عرض وتحكم بشتغيل الموسيقى إعادة المحاولة @@ -40,7 +40,7 @@ الإصدار عرض على الكود في Github التراخيص - تمت برمجة التطبيق من قبل OxygenCobalt + تمت برمجة التطبيق من قبل الكساندر كابيهارت الإعدادات المظهر @@ -192,4 +192,27 @@ مزيج Wiki أغنية + أتجاه + أختيار + قوائم التشغيل + قائمة التشغيل + تم خلق قائمة التشغيل + المزيد + حذف + تم النسخ + إضافة إلى قائمة التشغيل + مشاركة + تعديل + إعادة التسمية + تمت الإضافة إلى قائمة التشغيل + رتب حسب + مشاهدة + حذف قائمة التشغيل؟ + تم حذف قائمة التشغيل + تقرير + قائمة تشغيل جديدة + معلومات خاطئة + تم إعادة تسمية قائمة التشغيل + إعادة تسمية قائمة التشغيل + يظهر على \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d30ef1144..35e2abb16 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -300,4 +300,10 @@ Lied selbst spielen Richtung Sortieren nach + Auswahl-Bild + Auswahl + Mehr + Kopiert + Melden + Fehlerinformation \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 55677cbf1..262cfde7b 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -271,4 +271,7 @@ Tyhjennä tunnistevälimuisti ja lataa musiikkikirjasto kokonaan uudelleen (hitaampi mutta kattavampi) Kappale Näytä + Lisää + Kopioitu + Ilmoita virheestä \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 119710e5e..742007eb0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -242,7 +242,7 @@ Ajouter à la liste de lecture Créer une nouvelle liste de lecture Audio Matroska - Artistes chargés&nbsp;: %d + Artistes chargés : %d Rembobiner avant de revenir en arrière Image d\'artiste pour %s Aucune piste @@ -253,8 +253,8 @@ Renommer Impossible d\'effacer l\'état Modifier le mode de répétition - Albums chargés&nbsp;: %d - Durée totale&nbsp;: %s + Albums chargés : %d + Durée totale : %s Effacer la requête de recherche Image de la liste de lecture pour %s Disque %d @@ -288,7 +288,7 @@ Impossible de sauvegarder l\'état Aucune chanson Modification de %s - Genres chargés&nbsp;: %d + Genres chargés : %d Image de genre pour %s Codec audio gratuit sans perte (FLAC) %d sélectionnés @@ -304,4 +304,8 @@ Trier par Direction Sélection + En savoir plus + Copié + Signaler + Info sur l\'erreur \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index cec9b922a..c92aa6abf 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -301,4 +301,8 @@ Rendezés Kiválasztás Kép kiválasztás + További + Másolva + Jelentés + Hiba információ \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 21f166a7e..3b2486eb4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -211,4 +211,26 @@ Pustaka Pemutaran Ampersand (&) + Kompilasi + Kompilasi remix + EP + EP Live + Kompilasi + Kaset campuran + Lainnya + Soundtrack + Album live + + %d artis + + Single Live + EP + Kaset campuran + Single remix + Lagu + Single + Soundtrek + Kompilasi live + EP Remix + Single \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 70aa17ba9..0d9822a7f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,11 +1,11 @@ - 단순하고, 실용적인 안드로이드용 뮤직 플레이어입니다. + 단순하고, 실용적인 안드로이드용 음악 플레이어입니다. 음악 재생 제어 및 상태 확인 - 재시도 - 허가 + 다시 시도 + 허용 장르 아티스트 앨범 @@ -26,14 +26,14 @@ 오름차순 지금 재생 중 재생 - 셔플 + 무작위 재생 모든 곡에서 재생 앨범에서 재생 아티스트에서 재생 대기열 다음 곡 재생 대기열에 추가 - 대기열에 추가됨 + 대기열에 추가했습니다. 아티스트로 이동 앨범으로 이동 상태 저장됨 @@ -46,24 +46,24 @@ 정보 버전 소스 코드 - 라이센스 + 라이선스 Alexander Capehart가 개발 라이브러리 통계 설정 - 보고 느낌 + 모양과 느낌 테마 자동 - 밝음 - 어두움 + 라이트 테마 + 다크 테마 배색 검정 테마 - 어두운 테마에 검정색 사용 + 다크 테마에 검정색 사용 화면 라이브러리 탭 - 라이브러리 탭의 순서 및 표시할 탭 변경 + 라이브러리 탭 순서 및 표시할 탭 변경 둥근 UI 모드 - 기타 UI 요소 가장자리를 둥글게 표시 (앨범 커버도 둥글어짐) + 앨범 커버를 포함한 기타 UI의 가장자리를 둥글게 표시합니다. 알림 동작 사용자 정의 소리 헤드셋 자동 재생 @@ -71,32 +71,32 @@ ReplayGain 계획 트랙 선호 앨범 선호 - 앨법 재생 중인 경우 앨범 선호 + 앨범 재생 중인 경우 앨범 선호 ReplayGain 프리앰프 재생 중에 프리앰프를 적용하여 조정 태그로 조정 태그 없이 조정 - 주의: 프리앰프를 높게 설정하면 일부 소리 트랙이 왜곡될 수 있습니다. - 동작 + 주의: 프리앰프를 높게 설정하면 일부 오디오 트랙이 왜곡될 수 있습니다. + 개인화 라이브러리에서 재생할 때 무작위 재생 기억 - 새로운 곡을 재생할 때 무작위 재생 유지 + 새로운 곡을 재생할 때 무작위 재생 모드 유지 이전 곡으로 가기 전에 되감기 이전 곡으로 건너뛰기 전에 먼저 현재 트랙을 되감기 반복 재생 시 일시 중지 곡이 반복 재생될 때 일시 중지 내용 재생 상태 저장 - 현재 재생 상태를 즉시 저장 + 현재 재생 상태를 지금 저장합니다. 음악 새로고침 - 이미 저장된 태그를 가능한 활용하여 음악 라이브러리를 다시 만들기 + 캐시된 태그를 사용하여 음악 라이브러리를 다시 불러옵니다. 음악 없음 음악 불러오기 실패 - Auxio가 음악 라이브러리를 읽을 수 있는 권한이 필요함 - 이 작업을 처리할 수 있는 앱을 찾지 못함 + 앱에서 음악 라이브러리를 읽을 수 있는 권한이 필요합니다. + 이 작업을 처리할 수 있는 앱을 찾을 수 없습니다. 폴더 없음 - 이 폴더는 지원되지 않음 + 지원하지 않는 폴더입니다. 라이브러리에서 검색… @@ -107,8 +107,8 @@ 반복 방식 변경 무작위 재생 켜기 또는 끄기 모든 곡 무작위 재생 - 이 대기열의 곡 제거 - 이 대기열의 곡 이동 + 이 곡 제거 + 이 곡 이동 이 탭 이동 검색 기록 삭제 폴더 제거 @@ -157,8 +157,8 @@ %d 앨범 MPEG-4 오디오 - 자유 무손실 오디오 코덱 (FLAC) - 이전에 저장된 재생 상태 지우기 (있는 경우) + Free Lossless Audio Codec (FLAC) + 이전에 저장된 재생 상태 초기화 제외 추가한 폴더에서만 음악을 불러옵니다. 곡 속성 @@ -167,34 +167,34 @@ 샘플 속도 전송 속도 크기 - 모두 셔플 + 모두 무작위 재생 재생 중지 Ogg 오디오 - 마트로스카 오디오 + Matroska 오디오 %d Hz DJ믹스 라이브 컴필레이션 리믹스 편집 DJ믹스 이퀄라이저 - 셔플 + 무작위 재생 표시된 항목에서 재생 - 음악 라이브러리를 불러오는 중… + 음악 라이브러리 불러오는 중… 재생 상태 지우기 재생 상태 복원 음악 폴더 음악을 불러오는 위치 관리 추가한 폴더에서 음악을 불러오지 않습니다. 포함 - 다중값 구분자 - 여러 태그 값을 나타낼때의 구분자 설정 + 다중 값 구분 기호 + 태그 값이 여러 개일 때 태그를 구분할 기호를 설정합니다. 콤마 (,) 세미콜론 (;) - 슬래쉬 (/) + 슬래시 (/) 플러스 (+) 앰퍼샌드 (&) MPEG-1 오디오 - 추가된 날짜 + 추가한 날짜 상위 경로 맞춤형 재생 동작 버튼 반복 방식 @@ -208,20 +208,20 @@ 컴필레이션 라이브 형식 - 음악 아닌 것 제외 + 음악이 아닌 항목 제외 %d kbps - 고급 오디오 코딩 (AAC) + Advanced Audio Coding (AAC) 앨범 커버 빠름 고품질 - 이전에 저장된 재생 상태 복원 (있는 경우) - 재생상태를 복원할 수 없음 + 이전에 저장된 재생 상태 복원 + 재생 상태를 복원할 수 없습니다. 음악 라이브러리가 변경될 때마다 새로고침 (고정 알림 필요) 상태 지워짐 - 음악 불러오기 중 - 음악 불러오기 중 - 음악 라이브러리 추적중 + 음악 불러오는 중 + 음악 불러오는 중 + 음악 라이브러리 모니터링 중 상태 복원됨 EP 앨범 EP 앨범 @@ -234,72 +234,76 @@ 믹스테이프 리믹스 자동 새로고침 - 처리방식 + 모드 음악 라이브러리를 불러오는 중… (%1$d/%2$d) 장르 - 경고: 이 설정을 사용하면 일부 태그가 여러 값을 갖는 것으로 잘못 해석될 수 있습니다. 구분자로 읽히지 않도록 하려면 해당 구분자 앞에 백슬래시 (\\)를 붙입니다. + 경고: 이 설정을 사용하면 몇몇 태그가 다중 값을 가진 것으로 잘못 나타날 수 있습니다. 태그에서 구분 기호 앞에 백슬래시(\\)를 붙이면 구분 기호로 인식하지 않습니다. 항목 세부 정보에서 재생할 때 - 음악 라이브러리의 변경사항을 추적하는 중… + 음악 라이브러리 변경 사항 모니터링 중… 다음 곡으로 건너뛰기 - 팟캐스트와 같이 음악이 아닌 소리 파일 무시 - 공동작업자 숨기기 + 팟캐스트 등 음악이 아닌 오디오 파일 무시 + 공동 작업자 숨기기 앨범에 등장하는 아티스트만 표시 (자세히 태그된 라이브러리에 최적화) - 재생상태를 지울 수 없음 - 재생상태를 저장할 수 없음 + 재생 상태를 지울 수 없습니다. + 재생 상태를 저장할 수 없습니다. 음악 재탐색 %d 아티스트 - 태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함) + 태그 캐시를 지우고 음악 라이브러리를 처음부터 다시 생성합니다. 느리지만 더 완벽한 방식입니다. %d 선택됨 재설정 위키 장르에서 재생 %1$s, %2$s - 리플레이게인 + ReplayGain 사운드 및 재생 동작 구성 재생 폴더 - 앱의 테마 및 색상 변경 + 앱 테마 및 색상 변경 음악 라이브러리 이미지 - 음악 및 이미지 불러오기 방법 제어 + 음악 및 이미지 불러오기 방식 설정 지속 동작 - UI 제어 및 동작 커스텀 + UI 제어 및 동작 사용자 정의 내림차순 - 재생목록 - 재생목록 + 재생 목록 + 재생 목록 %s의 재생 목록 이미지 - 정렬할 때 기사 무시 - 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) + 적응형 정렬 + 정렬할 때 숫자나 \"the\"와 같은 단어를 무시합니다. 태그가 영어로 되어 있을 때 가장 잘 작동합니다. 새 재생 목록 만들기 - 새 재생목록 - 재생목록에 추가 - 생성된 재생목록 - 재생목록에 추가됨 - 재생목록 %d + 새 재생 목록 + 재생 목록에 추가 + 재생 목록을 만들었습니다. + 재생 목록에 추가했습니다. + 재생 목록 %d 노래 없음 삭제 - %s를 삭제하시겠습니까\? 이 취소 할 수 없습니다. + %s 항목을 삭제하시겠습니까\? 이 작업은 취소할 수 없습니다. 이름 바꾸기 - 재생목록 이름 바꾸기 - 재생목록을 삭제하시겠습니까\? - 편집하다 + 재생 목록 이름 바꾸기 + 재생 목록을 삭제하시겠습니까\? + 편집 에 나타납니다 - 공유하다 - 재생목록의 이름이 변경됨 + 공유 + 재생 목록의 이름을 바꿨습니다. 디스크 없음 - 재생목록이 삭제되었습니다 - %s 수정 중 + 재생 목록을 삭제했습니다. + %s 편집 중 노래 - 보다 - 포스 스퀘어 앨범 커버 - 모든 앨범 표지를 1:1 가로세로 비율로 자르기 + 보기 + 정사각형 앨범 커버 강제 + 모든 앨범 커버를 가로세로 1:1 비율로 자릅니다. 노래 따로 재생 방향 정렬 기준 선택 이미지 선택 + 더 보기 + 복사했습니다. + 오류 보고 + 오류 정보 \ 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 467ace0d2..6893313e6 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -2,10 +2,10 @@ Dainos Visos dainos - Ieškoti + Paieška Filtruoti Visos - Rūšiuoti + Rūšiavimas Pavadinimas Metai Trukmė @@ -34,7 +34,7 @@ Groti Licencijos Maišyti - Pridėta į eilę + Pridėtas į eilę Dainų ypatybės Failo pavadinimas Išsaugoti @@ -47,7 +47,7 @@ Tema Naudoti grynai juodą tamsią temą Paprastas, racionalus Android muzikos grotuvas. - Muzika kraunama + Muzikos pakraunimas Peržiūrėk ir valdyk muzikos grojimą Žanrai Pakartoti @@ -72,7 +72,7 @@ Albumo viršelis Giliai violetinė Stebėjimas muzikos biblioteka - Stebima tavo muzikos biblioteko dėl pakeitimų… + Stebimas tavo muzikos biblioteka dėl pakeitimų… Maišyti Maišyti viską Atkurta būsena @@ -123,7 +123,7 @@ Gyvai Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose) Ogg garsas - Sukūrė Alexanderis Capehartas + Sukūrė Alexanderis Capehartas (angl. Alexander Capehart) Pageidauti takelį Jokių aplankų Šis aplankas nepalaikomas @@ -159,7 +159,7 @@ Kai grojant iš elemento detalių Pašalinti aplanką Žanras - Ieškok savo bibliotekoje… + Ieškoti savo bibliotekoje… Ekvalaizeris Režimas Automatinis įkrovimas @@ -171,7 +171,7 @@ Kartojimo režimas Atidaryti eilę Išvalyti paieškos paraišką - Muzika nebus įkeliama iš pridėtų aplankų, kurių tu pridėsi. + Muzika nebus kraunama iš pridėtų aplankų, kurių tu pridėsi. Įtraukti Pašalinti šią dainą Groti iš visų dainų @@ -180,17 +180,17 @@ Groti iš atlikėjo Išvalyta būsena Neįtraukti - Muzika bus įkeliama iš aplankų, kurių tu pridėsi. + Muzika bus kraunama iš aplankų, kurių tu pridėsi. %d Hz Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo) - Įkeltos dainos: %d - Įkeltos žanrai: %d - Įkeltos albumai: %d - Įkeltos atlikėjai: %d - Kraunamas tavo muzikos biblioteka… (%1$d/%2$d) + Pakrautos dainos: %d + Pakrautos žanros: %d + Pakrauti albumai: %d + Pakrauti atlikėjai: %d + Kraunama tavo muzikos biblioteka… (%1$d/%2$d) Maišyti visas dainas Personalizuotas - Įspėjimas: Keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti tarpų. + Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti tarpų. Albumo viršelis %s Atlikėjo vaizdas %s Nėra grojančio muzikos @@ -214,7 +214,7 @@ DJ miksas Gyvai kompiliacija Remikso kompiliacija - Pagrindinis aplankas + Pirminis kelias Išvalyti anksčiau išsaugotą grojimo būseną (jei yra) Daugiareikšmiai separatoriai Pasvirasis brūkšnys (/) @@ -228,7 +228,7 @@ Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes Kablelis (,) Reguliavimas be žymų - Įspėjimas: Naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus naudojant atgalinį brūkšnį (\\). + Įspėjimas: naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\). Kabliataškis (;) Aukštos kokybės Atkurti grojimo būseną diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 7ccfca42d..1d385c7bc 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -93,11 +93,11 @@ ਐਪ ਦਾ ਥੀਮ ਅਤੇ ਰੰਗ ਬਦਲੋ ਥੀਮ ਸਵੈਚਾਲਿਤ - ਹਲਕਾ + ਸਫ਼ੈਦ ਗੂੜ੍ਹਾ ਰੰਗ ਸਕੀਮ ਕਾਲ੍ਹਾ ਥੀਮ - ਇੱਕ ਸ਼ੁੱਧ-ਕਾਲ੍ਹਾ ਗੂੜ੍ਹਾ ਥੀਮ ਵਰਤੋ + ਸ਼ਾਹ-ਕਾਲ਼ਾ ਥੀਮ ਵਰਤੋ ਗੋਲ ਮੋਡ ਵਾਧੂ UI ਤੱਤਾਂ \'ਤੇ ਗੋਲ ਕੋਨਿਆਂ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ (ਗੋਲਾਕਾਰ ਕਰਨ ਲਈ ਐਲਬਮ ਕਵਰਾਂ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ ) ਵਿਅਕਤੀਗਤ ਬਣਾਓ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 5cf5f433d..6716a415a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -16,7 +16,7 @@ Aleatório Tocando agora Fila - Reproduzir próxima + Reproduzir a seguir Adicionar à fila Adicionada à fila Ir para o artista @@ -145,7 +145,7 @@ Áudio MPEG-4 Áudio Ogg Áudio Matroska - Codificação de Audio Avançada (AAC) + Advanced Audio Coding (AAC) Free Lossless Audio Codec (FLAC) Mover esta música da fila Dinâmico @@ -295,4 +295,5 @@ Playlist renomeada Renomear playlist Aparece em + Apagar %s\? Esta ação não pode ser desfeita. \ 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 3a297776d..e02dbbe6c 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3,19 +3,19 @@ Tentar novamente Permitir - Gêneros + Géneros Artistas Álbuns Músicas Todas as músicas - Pesquisar + Procurar Filtrar Tudo - Classificação + Organizar Ascendente Reproduzir - Embaralhar - Tocando agora + Misturar + A tocar agora Fila Reproduzir a próxima Adicionar à fila @@ -34,10 +34,10 @@ Claro Escuro Automático - Cor de realce + Esquema de cores Áudio - Comportamento - Memorizar aleatorização + Personalizar + Memorizar musica misturada Nenhuma música encontrada @@ -79,19 +79,19 @@ Áudio MPEG-4 Artistas carregados: %d Duração total: %s - Falha no carregamento da música + Falha ao carregar música Nome - Prefira o álbum se estiver tocando - Nenhuma aplicação encontrada que possa lidar com esta tarefa + Prefira o álbum se estiver a tocar + Nenhuma aplicação encontrada que possa executar esta tarefa Ciano Contagem de músicas Formato Estatísticas da biblioteca Capa do álbum - Ano + Data Rápido Qualidade alta - Ação da barra de reprodução personalizada + Personalizar a barra de reprodução Modo de repetição Reproduzir do artista Pausar na repetição @@ -100,29 +100,29 @@ Esta pasta não é compatível Mover esta música da fila Remover pasta - Compilações de remix + Mistura de compilações Compilação ao vivo Disco Faixa Taxa de bits - Pular para o próximo + Avançar para o próximo Aviso: Alterar o pré-amplificador para um valor positivo alto pode resultar em picos em algumas faixas de áudio. - Ajuste com etiquetas + Ajustar com etiquetas Barra (/) Mais (+) Áudio Ogg Data adicionada Taxa de amostragem - Gravar + Salvar Separadores multi-valor Nome do ficheiro Tamanho - Ver propriedades + Propriedades Propriedades da música OK Adicionar Estado salvo - Estado liberado + Estado limpo Tema preto Limpar consulta de pesquisa Imagem de gênero para %s @@ -139,8 +139,8 @@ Ao vivo Duração Cancelar - A carregar a sua biblioteca de músicas… - Gira de onde a música deve ser carregada + A carregar biblioteca de músicas… + Configurar onde a música deve ser carregada Gênero Mantenha a reprodução aleatória ao reproduzir uma nova música Pular para a próxima música @@ -152,7 +152,7 @@ Excluir A música não será carregada das pastas que adicionar. Incluir - A música somente será carregada das pastas que adicionar. + A música será somente carregada das pastas que adicionar. Excluir não-música Ignorar ficheiros de áudio que não são música, tal como podcasts Configurar caracteres que denotam múltiplos valores de etiqueta @@ -167,30 +167,30 @@ Dinâmico Disco %d Capa do álbum para %s - Ajuste sem etiquetas + Ajustar sem etiquetas Conteúdo Gêneros carregados: %d A carregar música A carregar música - A monitorar a biblioteca de música + A monitorizar a biblioteca de música Equalizador Um reprodutor de música simples e racional para Android. Estado restaurado - Exibição + Mostrar Abas da biblioteca Altere a visibilidade e a ordem das abas da biblioteca Capas de álbuns Desligado Modo redondo - Usar ação de notificação alternativa - Reprodução automática do fone de ouvido - Sempre comece a tocar quando um fone de ouvido estiver conectado (pode não funcionar em todos os aparelhos) + Personalizar notificações + Reprodução automática dos auscultadores + Iniciar música quando os auscultadores forem conectados (pode não funcionar em todos os aparelhos) Estratégia do ganho de repetição Preferir álbum O pré-amplificador é aplicado ao ajuste existente durante a reprodução Reproduzir de todas as músicas - Pausa quando uma música se repete - Limpe o estado de reprodução salvo anteriormente (se houver) + Pausar quando uma música é repetida + Limpar o estado de reprodução salvo anteriormente (se houver) Restaurar o estado de reprodução Restaurar o estado de reprodução salvo anteriormente (se houver) Ativar ou desativar a reprodução aleatória @@ -207,8 +207,8 @@ Mixtape Remixes Artista - Gravar estado de reprodução - Salve o estado de reprodução atual agora + Gravar estado da reprodução + Salvar o estado de reprodução atual Limpar estado de reprodução Álbum ao vivo -%.1f dB @@ -220,20 +220,20 @@ Caminho principal Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas) %d Selecionadas - Mixes - Mix + Misturas DJ + DJ Mix Aleatório - Ocultar artistas colaboradores + Ocultar 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… + A Monitorizar alterações na sua biblioteca de músicas… Recarrega a biblioteca de músicas sempre que ela mudar (requer notificação fixa) - Redefinir + Repor Wiki - Visualize e controle a reprodução de música - Use um tema preto + Vêr e controlar a reprodução da música + Utilizar tema preto puro 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 @@ -244,10 +244,10 @@ %1$s, %2$s Não foi possível limpar a lista Não foi possível gravar a lista - Re-escanear músicas + Procurar músicas novamente Nenhuma lista pode ser restaurada Ícone do Auxio - Aleatorizar tudo + Misturar tudo Ao tocar da biblioteca Singles Single @@ -257,20 +257,55 @@ %d artistas %d artistas - Equalização de volume ReplayGain + Configurar ganho de repetição Descendente - Mude o tema e as cores do app - Personalize os controles e o comportamento da interface do usuário - Controle como a música e as imagens são carregadas + Mudar o tema e cores da app + Personalize os controlos e o comportamento do interface do utilizador + Controlar como a música e as imagens são carregadas Música Imagens - Configurar som e comportamento de reprodução + Configurar o som e comportamento da reprodução Reprodução Pastas Biblioteca - Estado de reprodução + Estado da reprodução E comercial (&) Comportamento - Ignorar artigos ao classificar + Classificação inteligente Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Direção + Seleção de imagem + Seleção + Tocar música sozinha + Listas de reprodução + Lista de reprodução %d + Lista de reprodução + Lista de reprodução criada + Mais + Imagem da lista de reprodução de %s + Eliminar + Nenhum disco + Copiado + Adicionar à lista de reprodução + Partilhar + Editar + Renomear + Adicionado à lista de reprodução + Nenhuma música + Recortar à capa dos álbuns numa proporção de 1:1 + A editar %s + Ordenar por + Visualizar + Música + Eliminar lista de reprodução + Criar nova lista de reprodução + Lista de reprodução eliminada + Relatório + Nova lista de reprodução + Informações de erro + Forçar capas em formato quadrado + Lista de reprodução renomeada + Renomear lista de reprodução + Excluir %s\? Não pode ser desfeito. + Só aparecer \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml new file mode 100644 index 000000000..a3630bd7c --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,307 @@ + + + Siva + Pametno sortiranje + Zbirke + Albumi + Zbirka remiksov + Počisti stanje predvajanja + Pojdi na album + Slika izvajalca za %s + Smer + Dodano v čakalno vrsto + Svetlo + Remiks album + Wiki + Samodejno + Slika izbire + Izbira + Preskoči na naslednjo pesem + Ime + Črna tema + Prikaži samo izvajalce, ki so neposredno navedeni na albumu (najbolje deluje v dobro označenih knjižnicah) + Zavihki knjižnice + Temna vijolična + Predvajaj pesem samostojno + Prednost albumu + Predvajaj iz prikazanega elementa + Samodejno ponovno nalaganje + Ni mogoče počistiti stanja + Podaljšane + Lastnosti pesmi + Spremenite način ponavljanja + Oranžna + Dodaj + Naključno predvajanje + DJ Miks + Prednost pesmi + Glasba ni v predavanju + Žanri + Preprost, racionalen predvajalnik glasbe za Android. + Nalaganje vaše glasbene knjižnice… + Previj nazaj pred skokom na prejšnjo pesem + %d kbps + Spremljanje vaše glasbene knjižnice za spremembe… + Prednost albumu če se album predvaja + Seznami predvajanja + Išči v knjižnici… + Ko se predvaja iz podrobnosti elementa + Ponovno naloži glasbo + Remiksi + Shrani stanje predvajanja + Opozorilo: Sprememba pred-ojačevalca na visoko pozitivno vrednost lahko privede do preseganja na nekaterih avdio posnetkih. + Ni datuma + Ponovno naloži glasbeno knjižnico, uporabi predpomnjene oznake, kadar je mogoče + Najdena ni bila nobena aplikacija, ki bi lahko opravila to nalogo + Prekliči + Vključi + Seznam predvajanja %d + Shrani trenutno stanje predvajanja zdaj + Preskoči na zadnjo pesem + Ponovno naloži glasbeno knjižnico vsakič, ko se zazna sprememba (zahteva vztrajno obvestilo) + Pot do datoteke + + %d pesem + %d pesmi + %d pesmi + %d pesmi + + Podaljšano v živo + Seznam predvajanja + Zbirka + Skrij soustvarjalce + Obdrži naključno predvajanje pri predvajanju nove pesmi + Obnašanje + Izklopljeno + MPEG-4 Audio + Shrani + Odpri čakalno vrsto + Mešanice + Izvajalec + Pravilno razvrsti imena, ki se začnejo z številkami ali besedami, kot so \'the\' (najbolje deluje z angleško glasbo) + Ime datoteke + Zelenkasto modra + Vztrajnost + Premešaj vse pesmi + Seznam predavanja ustvarjen + Celoten čas predvajanja: %s + Ni mogoče shraniti stanja + Pavza ob ponavljanju + Mape za glasbo + Zapomni si naključno predvajanje + Pojdi na izvajalca + Naloženih pesmi: %d + Premakni to pesem + Spremljanje glasbene knjižnice + Pokaži več + Ciano modra + Barvna shema + Slika seznama predvajanja za %s + Odstrani + Previj nazaj preden se preskoči nazaj + Naloženih žanrov: %d + Se predvaja + Odstrani to pesem + Stanje predvajanja shranjeno + Ni diska + Išči + Vedno začnite predvajati, ko se slušalke priključijo (morda ne deluje na vseh napravah) + Glasbene podlage + Premešaj vse + Dodaj v čakalno vrsto + Pred-ojačevalnik ReplayGain + MPEG-1 Audio + Ni mogoče obnoviti stanja + Spremenite temo in barve aplikacije + Poskusi znova + Prilagodi zvok in obnašanje predvajanja + Nadzorujte kako se glasba in slike nalagajo + Izgled in občutek + Izključi + Matroska Audio + Začasna prekinitev ob ponavljanju + Predvajaj + Nalaganje glasbe + Ni najdenih pesmi + Datum + Izprazni predpomnilnik oznak in popolnoma ponovno naloži glasbeno knjižnico (počasneje, vendar bolj popolno) + Pred-ojačevalec se uporablja na obstoječi prilagoditvi med predvajanjem + Predvajaj iz albuma + Glasba + Ta mapa ni podprta + Obnovi prej shranjeno stanje predvajanja (če obstaja) + Razvil Alexander Capehart + Odstrani mapo + Kopirano + Nalaganje glasbe ni uspelo + Album + Ko se predvaja iz knjižnice + Visoka kvaliteta + Prilagoditev brez oznak + Dodaj na seznam predvajanja + Datum vnosa + Deli + Album v živo + Uredi + Naslovnica albuma + Preimenuj + Plus (+) + Stanje predvajanja obnovljeno + %d Izbrano + Neznan izvajatelj + Slike + + %d izvajalec + %d izvajalca + %d izvajalci + %d izvajalcev + + Stanje predvajanja počiščeno + Prikaz + %1$s, %2$s + Ogg Audio + Vse + Poševnica (/) + Dodano na seznam predvajanja + Singl v živo + Ni pesmi + Podaljšano + Pesmi + Mape + Prireži vse naslovnice albumov v razmerje 1:1 + Prilagojeno dejanje na vrstici za predvajanje + Indigo modra + -%.1f dB + Nalaganje glasbe + In (&) + Število pesmi + Sortiraj + Vijolična + Brezplačni format brez izgub zvoka (FLAC) + Neznan žanr + +%.1f dB + Prilagodi + Urejanje %s + Preskoči na naslednjo + Način ponavljanja + + %d album + %d albuma + %d albumi + %d albumov + + Disk + Nalaganje vaše glasbene knjižnice... (%1$d/%2$d) + Počisti iskalno poizvedbo + Naraščajoče + Roza + Vse pesmi + O aplikaciji + Disk %d + Omogočite zaobljene robove na dodatnih elementih uporabniškega vmesnika (zahteva zaobljene naslovnice albumov) + Naslovnica albuma za %s + V živo + Spremenite vidnost in vrstni red zavihkov knjižnice + Počisti shranjeno stanje predvajanja (če obstaja) + Sortiraj po + Ogled + Ustavi predvajanje + Mežanica + Glasba se bo nalagala samo iz map, ki jih dodate. + Način + Remiks singla + Auxio potrebuje dovoljenje za branje vaše glasbene knjižnice + Tema + Knjižnica + Statistika knjižnice + Izenačevalnik + Premakni ta zavihek + Pesem + Slika žanra za %s + Nastavitev virov za nalaganje glasbe + DJ Miksi + Odstrani seznam predvajanja\? + Modra + Temno modra + Zaobljen način + Naloženih izvajalcev: %d + Zvok + Glasba se ne bo nalagala iz the map, ki jih dodate. + Rdeča + Dinamično + Temno + Vklopite ali izklopite naključno predvajanje + Žanr + Predvajanje + Vredu + Ustvari nov seznam predvajanja + Singl + Seznam predvajanja odstranjen + Dovoli + Predvajaj iz vseh pesmi + Obnovi stanje predvajanja + Prilagoditev z oznakami + Predvajanje ob priključitvi slušalk + Vejica (,) + Auxio ikona + Skladba %d + Filtriraj + Prezri avdio datoteke, ki niso glasba, na primer podkaste + Glasbena podlaga + Prilagojeno dejanje v obvestilu + Ponastavi nastavitve + Prijavi napako + Izključi ne-glasbo + Predvajaj naslednje + Izvajalci + Skladba + Naloženih albumov: %d + Nov seznam predvajanja + Informacije napake + Premešaj + Napredno avdio kodiranje (AAC) + Prisilite uporabo kvadratnih naslovnic albumov + Zbirka pesmi v živo + Naslovnice albumov + Rumena + Zelena + Različica + Ogled lastnosti + Velikost + Padajoče + Podaljšan remiks + Podpičje (;) + Hitro + Seznam predvajanja preimenovan + Predvajaj ali začasno ustavi + Rjava + ReplayGain strategija + Izvorna koda + Predvajaj iz izvajalca + Ni map + Prilagoditev kontrol uporabniškega vmesnika in obnašanja + Hitrost vzorčenja + Čakalna vrsta + Ločila za več vrednosti + ReplayGain Tehnologija + Singli + Pregled in nadzor predvajanja glasbe + Uporabite čisto črno temo + Ponovno preglej glasbeno knjižnico + Licence + Format + Vsebina + Preimenuj seznam predvajanja + Bitna hitrost + Odstraniti %s\? Tega ni mogoče razveljaviti. + Nastavitve + Konfigurirajte znake, ki označujejo več vrednosti zaporedoma + Ni skladbe + Opozorilo: Uporaba te nastavitve lahko povzroči, da se nekatere oznake napačno interpretirajo kot oznake z več vrednostmi. To lahko rešite tako, da neželene ločevalne znake predhodno označite z vzvratno poševnico (\\). + Temno zelena + Predvajaj iz žanra + Trajanje + Limeta + Sodeloval pri + %d Hz + \ 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 559ef2336..5e77b99ab 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -197,7 +197,7 @@ Tekli Karışık kaset Canlı derleme - Remiks derlemeler + Remiks derlemesi Ekolayzır Canlı EP Remiks EP @@ -228,8 +228,8 @@ %d sanatçı %d sanatçılar - Karmalar - Karma + DJ Miksleri + DJ Mix 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 @@ -278,4 +278,30 @@ Yeniden Adlandır Oynatma Listesini Yeniden Adlandır Oynatma listesini silmek istiyor musun\? + Yön + Seçim görüntüsü + Seçim + Şarkıyı kendi kendine çal + Çalma listesi %d + Oynatma listesi oluşturuldu + Daha fazla + Disk yok + Kopyalandı + Çalma listesine ekle + Paylaş + Düzenle + Çalma listesine eklendi + Şarkı yok + Tüm albüm kapaklarını 1:1 en boy oranına kırp + %s düzenleniyor + Göre sırala + Görünüm + Şarkı + Çalma listesi silindi + Rapor + Hata bilgisi + Kare albüm kapaklarına zorla + Çalma listesi yeniden adlandırıldı + %s silinsin mi\? Geri alınamaz. + Üzerinde görünür \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 000000000..107936eaf --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,23 @@ +Auxio est un lecteur de musique local doté d'une UI/UX rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une librairie moderne de lecture de media, Auxio supporte une libaririe et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'android dépassées. Pour faire simple, il joue votre musique . + +Fonctionnalités + +- Lecture basée sur l'ExoPlayer Media3 +- UI réactive dérivée des dernières lignes directrices en Material Design +- UX orientée qui mets l'accent sur la facilité d'utilisation plutôt que sur les usages +- Comportement personnalisable +- Reconnaît les numéros de disque, les artistes multiples, les types de support, +les dates précises/originales, le classement par tags, and plus encore +- Système de reconaissance d'artistes avancé qui unifie artistes et artistes de l'album +- Carte SD reconnue par le système de dossiers +- Fonction de liste de lecture efficace +- Statut de lecture persistant +- Support complet de ReplayGain (pour les fichiers MP3, FLAC, OGG, OPUS, et MP4) +- Support pour égaliseur externe (ex. Wavelet) +- Navigation bord-à-bord +- Couvertures intégrées reconnues +- Recherche intégrée +- Lecture automatique pour les casques +- Widgets stylisés qui s'adaptent automatiquement à leur taille +- Complètement privé et hors-ligne +- On arrondit pas les couvertures d'albums (Sauf si vous le voulez. Dans ce cas c'est possible.) diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt new file mode 100644 index 000000000..65e70b61e --- /dev/null +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -0,0 +1,21 @@ +Auxio é um leitor de música local com uma UI/UX rápida e fiável sem as muitas funcionalidades inúteis presentes noutros leitores de música. Construído a partir de bibliotecas de reprodução de mídia modernas, Auxio tem suporte de biblioteca superior e qualidade de audição em comparação com outras aplicações que usam funcionalidade Android desatualizadas. Em suma, toca música. + +Caraterísticas + +- Reprodução baseada em Media3 ExoPlayer +- Snappy UI derivada das mais recentes diretrizes de Material Design +- UX opinativa que prioriza a facilidade de uso sobre casos de borda +- Comportamento personalizável +- Suporte para números de disco, vários artistas, tipos de lançamento, +datas precisas/originais, tags de classificação e muito mais +- Sistema avançado de artistas que unifica artistas e artistas de álbuns +- Gerenciamento de pastas com reconhecimento de cartão SD +- Funcionalidade de playlisting confiável +- Persistência do estado de reprodução +- Suporte completo ReplayGain (em arquivos MP3, FLAC, OGG, OPUS e MP4) +- Suporte de equalizador externo (ex. Wavelet) +- De ponta a ponta +- Suporte de capas embutidas +- Funcionalidade de pesquisa +- Reprodução automática de auscultadores +- Elegante diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt new file mode 100644 index 000000000..afb175103 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/short_description.txt @@ -0,0 +1 @@ +Um leitor de música simples diff --git a/fastlane/metadata/android/sl/full_description.txt b/fastlane/metadata/android/sl/full_description.txt new file mode 100644 index 000000000..c8ee9819d --- /dev/null +++ b/fastlane/metadata/android/sl/full_description.txt @@ -0,0 +1,22 @@ +Auxio je lokalni predvajalnik glasbe z hitrim in zanesljivim uporabniškim vmesnikom brez večino nepotrebnih funkcij, ki jih najdete v drugih predvajalnikih glasbe. Zgrajen na sodobnih knjižnicah za predvajanje medijskih vsebin, Auxio ponuja izjemno podporo za knjižnico in kakovost poslušanja v primerjavi z aplikacijami, ki uporabljajo zastarelo funkcionalnost Androida. Skratka, predvaja glasbo. + +Lastnosti + +- Predvajanje temelji na Media3 ExoPlayer predvajalniku +- Hiter uporabniški vmesnik, izpeljan iz najnovejših smernic oblikovanja gradiva (Material Design) +- Samostojno premišljena uporabniška izkušnja, ki postavlja enostavnost uporabe pred izjemne primere, ki se zelo redko zgodijo +- Prilagodljivo obnašanje +- Podpora za številke diskov, več izvajalcev, vrste izdaj, natančne/izvirne datume, razvrščalne oznake in še več +- Napreden sistem izvajalcev, ki združuje izvajalce in izvajalce albumov +- Upravljanje map na SD kartici +- Zanesljiva funkcionalnost ustvarjanja seznama predvajanja +- Trajnost stanja predvajanja +- Popolna podpora za ReplayGain tehnologijo (za MP3, FLAC, OGG, OPUS in MP4 datoteke) +- Podpora za zunanje izenačevalnike (npr. Wavelet) +- Od roba do roba +- Podpora za vdelane naslovnice albumov +- Funkcionalnost iskanja +- Avtomatski zagon ob priključitvi slušalk +- Elegantni pripomočki, ki se samodejno prilagajajo svoji velikosti +- Popolnoma zasebno in brez povezave +- Brez zaobljenih naslovnic albumov (če jih ne želite; če pa želite, jih lahko omogočite) diff --git a/fastlane/metadata/android/sl/short_description.txt b/fastlane/metadata/android/sl/short_description.txt new file mode 100644 index 000000000..568819de9 --- /dev/null +++ b/fastlane/metadata/android/sl/short_description.txt @@ -0,0 +1 @@ +Preprost, racionalen predvajalnik glasbe diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt index e5b9134cf..86460ef08 100644 --- a/fastlane/metadata/android/tr/full_description.txt +++ b/fastlane/metadata/android/tr/full_description.txt @@ -1,19 +1,23 @@ -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. +Auxio, diğer müzik çalarlarda bulunan birçok gereksiz özellik olmadan hızlı, güvenilir bir UI / UX'a sahip yerel bir müzik oynatıcıdır. Modern medya oynatma kütüphaneleri üzerine inşa edilen Auxio, eski android işlevselliğini kullanan diğer uygulamalara kıyasla üstün kütüphane desteği ve dinleme kalitesine sahiptir. Kısacası, Müzik çalar. Özellikler -- ExoPlayer tabanlı oynatma +- Media3 ExoPlayer tabanlı oynatma - En son Materyal Tasarım yönergelerinden türetilen hızlı kullanıcı arayüzü - Uç durumlardan ziyade kullanım kolaylığına öncelik veren fikir sahibi kullanıcı deneyimi - Özelleştirilebilir davranış -- Doğru meta verilere öncelik veren gelişmiş medya indeksleyici +- Disk numaraları, çoklu sanatçılar, sürüm türleri için destek, +kesin/orijinal tarihler, sıralama etiketleri ve daha fazlası +- Sanatçıları ve albüm sanatçılarını birleştiren gelişmiş sanatçı sistemi - SD Card-aware klasör yönetimi -- Güvenilir oynatma durumu kalıcılığı -- Tam ReplayGain desteği (MP3, MP4, FLAC, OGG ve OPUS'ta) +- Güvenilir çalma listesi işlevi +- Oynatma durumu kalıcılığı +- Tam ReplayGain desteği (MP3, FLAC, OGG, OPUS ve MP4 dosyalarında) +- Harici ekolayzer desteği (örn. Wavelet) - Kenardan kenara - Gömülü kapak desteği -- Arama İşlevselliği +- Arama işlevi - Kulaklık otomatik oynatma - Boyutlarına otomatik olarak uyum sağlayan şık widget'lar - Tamamen özel ve çevrimdışı -- Yuvarlak albüm kapakları yok (İstediğiniz zaman açıp kapatabilirsiniz.) +- Yuvarlak albüm kapakları yok (İstemediğiniz sürece. O zaman yapabilirsiniz.) From 08f3137c5b889bc5ecc1471c30330eb2fe828fcd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Nov 2023 22:43:17 -0700 Subject: [PATCH 102/127] app: start service on draw-time Recently, Android 14 seemed to have finally made it impossible to start services in onStart. I never realized this error since I thought onStart signified the beginning of the foreground state, when it was actually onResume. I think it only worked prior due to race conditions. Try to fix it by moving the service starting code to onResume. See #608. --- app/src/main/java/org/oxycblt/auxio/MainActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 725f60444..c98d89cdd 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -68,8 +68,8 @@ class MainActivity : AppCompatActivity() { logD("Activity created") } - override fun onStart() { - super.onStart() + override fun onResume() { + super.onResume() startService(Intent(this, IndexerService::class.java)) startService(Intent(this, PlaybackService::class.java)) From a99b0ff615edfd47aa8a807c08458ea24c3c3d70 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Nov 2023 22:54:30 -0700 Subject: [PATCH 103/127] ui: fix hebrew string crash Caused by weblate once again conflating "many" with "other". I really need to report that. Resolves #575. --- CHANGELOG.md | 1 + app/src/main/res/values-iw/strings.xml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1707f1a..370448089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed app restart being required when changing intelligent sorting or music separator settings - Fixed widget/notification actions not working on Android 14 +- Fixed app crash when using hebrew language ## 3.2.0 diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index e864933bf..e2ad903ea 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -215,7 +215,7 @@ אומן אחד שני אומנים - %d אומנים + %d אומנים לכלול רענון מוזיקה @@ -226,12 +226,12 @@ שיר אחד שני שירים - %d שירים + %d שירים אלבום אחד שני אלבומים - %d אלבומים + %d אלבומים שונה שם רשימת ההשמעה רשימת השמעה נמחקה From 52697ef8910708d10a811c8e7f25d26571f23bb6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Nov 2023 23:01:51 -0700 Subject: [PATCH 104/127] detail: allow adding to playlist in playlist The playback view can still add to a playlist from a playlist, so I have to implement this navigation route no matter what. --- .../java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt | 6 +++++- app/src/main/res/navigation/inner.xml | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index ed460bc33..540017724 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -328,7 +328,11 @@ class PlaylistDetailFragment : logD("Deleting ${decision.playlist}") PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) } - is PlaylistDecision.Add, + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} songs to a playlist") + PlaylistDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } is PlaylistDecision.New -> error("Unexpected playlist decision $decision") } findNavController().navigateSafe(directions) diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index a974b3360..43c0d52c3 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -379,6 +379,9 @@ + From ce5b9e35c786dca35d71f9e38a15838a2f6af43a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Nov 2023 23:14:50 -0700 Subject: [PATCH 105/127] util: inline log functions A bit of a stupid workaround now that I use Timber and not my self-rolled logging solution. Timber uses some weird heuristics for names that I don't. --- app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index 4b1f800b4..e86983c86 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -26,14 +26,14 @@ import timber.log.Timber * * @param obj The object to log. */ -fun logD(obj: Any?) = logD("$obj") +inline fun Any.logD(obj: Any?) = logD("$obj") /** * Log a string message to the debug channel. Automatically handles tags. * * @param msg The message to log. */ -fun logD(msg: String) { +inline fun Any.logD(msg: String) { if (BuildConfig.DEBUG && !copyleftNotice()) { Timber.d(msg) } @@ -44,21 +44,21 @@ fun logD(msg: String) { * * @param msg The message to log. */ -fun logW(msg: String) = Timber.w(msg) +inline fun logW(msg: String) = Timber.w(msg) /** * Log a string message to the error channel. Automatically handles tags. * * @param msg The message to log. */ -fun logE(msg: String) = Timber.e(msg) +inline fun logE(msg: String) = Timber.e(msg) /** * Please don't plagiarize Auxio! You are free to remove this as long as you continue to keep your * source open. */ @Suppress("KotlinConstantConditions") -private fun copyleftNotice(): Boolean { +fun copyleftNotice(): Boolean { if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" && BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") { Timber.d( From d926e19819a9d286e4b36a98b3d0519fdd2c4e03 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Nov 2023 23:22:46 -0700 Subject: [PATCH 106/127] all: cleanup --- .../java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt | 1 - app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt | 4 ++-- app/src/main/res/values-iw/strings.xml | 2 +- app/src/main/res/values-lt/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-sl/strings.xml | 2 +- 6 files changed, 6 insertions(+), 6 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 acf47a421..5b1edce73 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 @@ -106,7 +106,6 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt * * @param adapterIndex The index of the queue item to play. Does nothing if the index is out of * range. - * @param playIfPaused Start playing after switching even if it currently is paused */ fun goto(adapterIndex: Int) { if (adapterIndex !in queue.value.indices) { diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index e86983c86..bc1197af4 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -26,14 +26,14 @@ import timber.log.Timber * * @param obj The object to log. */ -inline fun Any.logD(obj: Any?) = logD("$obj") +inline fun logD(obj: Any?) = logD("$obj") /** * Log a string message to the debug channel. Automatically handles tags. * * @param msg The message to log. */ -inline fun Any.logD(msg: String) { +inline fun logD(msg: String) { if (BuildConfig.DEBUG && !copyleftNotice()) { Timber.d(msg) } diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index e2ad903ea..d74cc9319 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -205,7 +205,7 @@ כחול עמוק אפור דינמי - המוזיקה שלך בטעינה... (‎%1$d/%2$d) + המוזיקה שלך בטעינה… (‎%1$d/%2$d) דיסק %d ניהול המקומות שמהם תיטען מוזיקה אין שירים diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 6893313e6..0b2138093 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -245,6 +245,7 @@ %d atlikėjas (-a) %d atlikėjai %d atlikėjų + %d atlikėjų Perskenuoti muziką Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a6b3daec9..3a0906840 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index a3630bd7c..5d8da013d 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -191,7 +191,7 @@ %d albumov Disk - Nalaganje vaše glasbene knjižnice... (%1$d/%2$d) + Nalaganje vaše glasbene knjižnice… (%1$d/%2$d) Počisti iskalno poizvedbo Naraščajoče Roza From 23dac3b4b7851f3126a8be2aa7ae1cc96a3415bd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 09:58:29 -0700 Subject: [PATCH 107/127] music: bump cache db version Made some changes to tag interpretation, so all users have to rescan now. --- .../main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 2e3e8a944..c76ecf5a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 34, exportSchema = false) +@Database(entities = [CachedSong::class], version = 35, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } From 917e6c0737ac616013c09b723d78f9e3e13fb5f6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 09:59:45 -0700 Subject: [PATCH 108/127] build: bump to v3.2.1 Bump to version 3.2.1 (35). --- CHANGELOG.md | 2 ++ app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/36.txt | 3 +++ fastlane/metadata/android/en-US/full_description.txt | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/36.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 370448089..1ecfb63b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ or music separator settings - Fixed widget/notification actions not working on Android 14 - Fixed app crash when using hebrew language +- Fixed app crash when adding to a playlist while in the playlist detail view +- Fixed music loading failing in some cases on Android 14 ## 3.2.0 diff --git a/app/build.gradle b/app/build.gradle index a17b46060..f6a0d3abc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.2.0" - versionCode 35 + versionName "3.2.1" + versionCode 36 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/36.txt b/fastlane/metadata/android/en-US/changelogs/36.txt new file mode 100644 index 000000000..b0ac8dc87 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/36.txt @@ -0,0 +1,3 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +This release fixes several critical issues identified in the previous version. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index b43251ee6..3f4927359 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -20,4 +20,4 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers (Unless you want them. Then you can.) \ No newline at end of file +- No rounded album covers (by default) \ No newline at end of file From df7ec27d1cd9e89142ccb92e4417b7a7d32196a3 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 10:00:41 -0700 Subject: [PATCH 109/127] playback: temp revert swipe to next behavior It's too buggy to be in a release currently, I have to disable it for now. --- .../auxio/playback/PlaybackPanelFragment.kt | 95 ++++---------- .../auxio/playback/ui/PlaybackPagerAdapter.kt | 124 ------------------ .../layout-h480dp/fragment_playback_panel.xml | 52 +++++++- .../res/layout-h480dp/item_playback_song.xml | 56 -------- .../fragment_playback_panel.xml | 50 ++++++- .../res/layout-sw600dp/item_playback_song.xml | 56 -------- .../res/layout/fragment_playback_panel.xml | 56 +++++++- .../main/res/layout/item_playback_song.xml | 55 -------- 8 files changed, 166 insertions(+), 378 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt delete mode 100644 app/src/main/res/layout-h480dp/item_playback_song.xml delete mode 100644 app/src/main/res/layout-sw600dp/item_playback_song.xml delete mode 100644 app/src/main/res/layout/item_playback_song.xml diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index 53cbbe7fb..b7228d508 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,26 +29,18 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint -import java.lang.reflect.Field -import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel -import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.queue.QueueViewModel +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.ui.PlaybackPagerAdapter import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast @@ -66,14 +58,11 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, - StyledSeekBar.Listener, - PlaybackPagerAdapter.Listener { + StyledSeekBar.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() - private val queueModel: QueueViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null - private var coverAdapter: PlaybackPagerAdapter? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackPanelBinding.inflate(inflater) @@ -110,13 +99,19 @@ class PlaybackPanelFragment : } } - // cover carousel adapter - coverAdapter = PlaybackPagerAdapter(this) - binding.playbackCoverPager.apply { - adapter = coverAdapter - registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) - val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView - recycler.isNestedScrollingEnabled = false + // Set up marquee on song information, alongside click handlers that navigate to each + // respective item. + binding.playbackSong.apply { + isSelected = true + setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } + } + binding.playbackArtist.apply { + isSelected = true + setOnClickListener { navigateToCurrentArtist() } + } + binding.playbackAlbum.apply { + isSelected = true + setOnClickListener { navigateToCurrentAlbum() } } binding.playbackSeekBar.listener = this @@ -136,14 +131,15 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) - collectImmediately(queueModel.queue, ::updateQueue) - collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { equalizerLauncher = null - coverAdapter = null binding.playbackToolbar.setOnMenuItemClickListener(null) + // Marquee elements leak if they are not disabled when the views are destroyed. + binding.playbackSong.isSelected = false + binding.playbackArtist.isSelected = false + binding.playbackAlbum.isSelected = false } override fun onMenuItemClick(item: MenuItem): Boolean { @@ -174,18 +170,6 @@ class PlaybackPanelFragment : playbackModel.seekTo(positionDs) } - private fun updateQueue(queue: List) { - coverAdapter?.update(queue, UpdateInstructions.Replace(0)) - } - - private fun updateQueuePosition(position: Int) { - val pager = requireBinding().playbackCoverPager - val distance = abs(pager.currentItem - position) - if (distance != 0) { - pager.setCurrentItem(position, distance == 1) - } - } - private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. @@ -193,7 +177,12 @@ class PlaybackPanelFragment : } val binding = requireBinding() + val context = requireContext() logD("Updating song display: $song") + binding.playbackCover.bind(song) + binding.playbackSong.text = song.name.resolve(context) + binding.playbackArtist.text = song.artists.resolveNames(context) + binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -223,43 +212,11 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - override fun navigateToCurrentSong() { - playbackModel.song.value?.let(detailModel::showAlbum) - } - - override fun navigateToCurrentArtist() { + private fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } - override fun navigateToCurrentAlbum() { + private fun navigateToCurrentAlbum() { playbackModel.song.value?.let { detailModel.showAlbum(it.album) } } - - override fun navigateToMenu() { - // TODO - } - - private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : - OnPageChangeCallback() { - - private var targetPosition = RecyclerView.NO_POSITION - - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - targetPosition = position - } - - override fun onPageScrollStateChanged(state: Int) { - super.onPageScrollStateChanged(state) - if (state == ViewPager2.SCROLL_STATE_IDLE && - targetPosition != RecyclerView.NO_POSITION && - targetPosition != queueViewModel.index.value) { - queueViewModel.goto(targetPosition) - } - } - } - - private companion object { - val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt deleted file mode 100644 index d0f176231..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * PlaybackPagerAdapter.kt is part of Auxio. - * - * 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.ui - -import android.view.ViewGroup -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import kotlin.jvm.internal.Intrinsics -import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding -import org.oxycblt.auxio.list.adapter.FlexibleListAdapter -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.util.inflater - -/** @author Koitharu, Alexander Capehart (OxygenCobalt) */ -class PlaybackPagerAdapter(private val listener: Listener) : - FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { - return CoverViewHolder.from(parent) - } - - override fun onBindViewHolder(holder: CoverViewHolder, position: Int) { - holder.bind(getItem(position), listener) - } - - override fun onViewRecycled(holder: CoverViewHolder) { - holder.recycle() - super.onViewRecycled(holder) - } - - interface Listener { - fun navigateToCurrentArtist() - - fun navigateToCurrentAlbum() - - fun navigateToCurrentSong() - - fun navigateToMenu() - } -} - -class CoverViewHolder private constructor(private val binding: ItemPlaybackSongBinding) : - RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver { - init { - binding.root.layoutParams = - RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) - } - - /** - * Bind new data to this instance. - * - * @param item The new [Song] to bind. - */ - fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) { - val context = binding.root.context - binding.playbackCover.bind(item) - // binding.playbackCover.bind(item) - binding.playbackSong.apply { - text = item.name.resolve(context) - setOnClickListener { listener.navigateToCurrentSong() } - } - binding.playbackArtist.apply { - text = item.artists.resolveNames(context) - setOnClickListener { listener.navigateToCurrentArtist() } - } - binding.playbackAlbum.apply { - text = item.album.name.resolve(context) - setOnClickListener { listener.navigateToCurrentAlbum() } - } - setSelected(true) - } - - fun recycle() { - // Marquee elements leak if they are not disabled when the views are destroyed. - // TODO: Move to TextView impl to avoid having to deal with lifecycle here - setSelected(false) - } - - private fun setSelected(value: Boolean) { - binding.playbackSong.isSelected = value - binding.playbackArtist.isSelected = value - binding.playbackAlbum.isSelected = value - } - - companion object { - /** - * Create a new instance. - * - * @param parent The parent to inflate this instance from. - * @return A new instance. - */ - fun from(parent: ViewGroup) = - CoverViewHolder(ItemPlaybackSongBinding.inflate(parent.context.inflater)) - - /** A comparator that can be used with DiffUtil. */ - val DIFF_CALLBACK = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = - oldItem.uid == newItem.uid - - override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { - return Intrinsics.areEqual(oldItem, newItem) - } - } - } -} diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index 28f69b2b2..e68de3423 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,15 +16,55 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/res/layout-h480dp/item_playback_song.xml b/app/src/main/res/layout-h480dp/item_playback_song.xml deleted file mode 100644 index 9ce0bcf47..000000000 --- a/app/src/main/res/layout-h480dp/item_playback_song.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index b7ead10f6..eecb65e6e 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -16,14 +16,54 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - + + + + + + + tools:text="Album Name" /> + - + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/item_playback_song.xml b/app/src/main/res/layout-sw600dp/item_playback_song.xml deleted file mode 100644 index 9ce0bcf47..000000000 --- a/app/src/main/res/layout-sw600dp/item_playback_song.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index e873722e1..857cb7545 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -16,22 +16,64 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - + + + + + + + + + + + + + @@ -117,4 +159,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/layout/item_playback_song.xml b/app/src/main/res/layout/item_playback_song.xml deleted file mode 100644 index 3e8c0c6a1..000000000 --- a/app/src/main/res/layout/item_playback_song.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file From 0016c778364fea916d35220f67cfcaeae13692b9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 10:18:03 -0700 Subject: [PATCH 110/127] music: bump cache version Accidentally bumped it to version code 35, when this will be version code 36. --- .../main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index c76ecf5a6..9eb52bbc6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 35, exportSchema = false) +@Database(entities = [CachedSong::class], version = 36, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } From 7fef5a27daf408ed4471ff9ba8e0259d9fcf37b1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:05:14 -0700 Subject: [PATCH 111/127] Revert "playback: temp revert swipe to next behavior" This reverts commit df7ec27d1cd9e89142ccb92e4417b7a7d32196a3. --- .../auxio/playback/PlaybackPanelFragment.kt | 95 ++++++++++---- .../auxio/playback/ui/PlaybackPagerAdapter.kt | 124 ++++++++++++++++++ .../layout-h480dp/fragment_playback_panel.xml | 52 +------- .../res/layout-h480dp/item_playback_song.xml | 56 ++++++++ .../fragment_playback_panel.xml | 50 +------ .../res/layout-sw600dp/item_playback_song.xml | 56 ++++++++ .../res/layout/fragment_playback_panel.xml | 56 +------- .../main/res/layout/item_playback_song.xml | 55 ++++++++ 8 files changed, 378 insertions(+), 166 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt create mode 100644 app/src/main/res/layout-h480dp/item_playback_song.xml create mode 100644 app/src/main/res/layout-sw600dp/item_playback_song.xml create mode 100644 app/src/main/res/layout/item_playback_song.xml diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b7228d508..53cbbe7fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,18 +29,26 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint +import java.lang.reflect.Field +import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.queue.QueueViewModel import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.ui.PlaybackPagerAdapter import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast @@ -58,11 +66,14 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, - StyledSeekBar.Listener { + StyledSeekBar.Listener, + PlaybackPagerAdapter.Listener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() + private val queueModel: QueueViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null + private var coverAdapter: PlaybackPagerAdapter? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackPanelBinding.inflate(inflater) @@ -99,19 +110,13 @@ class PlaybackPanelFragment : } } - // Set up marquee on song information, alongside click handlers that navigate to each - // respective item. - binding.playbackSong.apply { - isSelected = true - setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } - } - binding.playbackArtist.apply { - isSelected = true - setOnClickListener { navigateToCurrentArtist() } - } - binding.playbackAlbum.apply { - isSelected = true - setOnClickListener { navigateToCurrentAlbum() } + // cover carousel adapter + coverAdapter = PlaybackPagerAdapter(this) + binding.playbackCoverPager.apply { + adapter = coverAdapter + registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) + val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView + recycler.isNestedScrollingEnabled = false } binding.playbackSeekBar.listener = this @@ -131,15 +136,14 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) + collectImmediately(queueModel.queue, ::updateQueue) + collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { equalizerLauncher = null + coverAdapter = null binding.playbackToolbar.setOnMenuItemClickListener(null) - // Marquee elements leak if they are not disabled when the views are destroyed. - binding.playbackSong.isSelected = false - binding.playbackArtist.isSelected = false - binding.playbackAlbum.isSelected = false } override fun onMenuItemClick(item: MenuItem): Boolean { @@ -170,6 +174,18 @@ class PlaybackPanelFragment : playbackModel.seekTo(positionDs) } + private fun updateQueue(queue: List) { + coverAdapter?.update(queue, UpdateInstructions.Replace(0)) + } + + private fun updateQueuePosition(position: Int) { + val pager = requireBinding().playbackCoverPager + val distance = abs(pager.currentItem - position) + if (distance != 0) { + pager.setCurrentItem(position, distance == 1) + } + } + private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. @@ -177,12 +193,7 @@ class PlaybackPanelFragment : } val binding = requireBinding() - val context = requireContext() logD("Updating song display: $song") - binding.playbackCover.bind(song) - binding.playbackSong.text = song.name.resolve(context) - binding.playbackArtist.text = song.artists.resolveNames(context) - binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -212,11 +223,43 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun navigateToCurrentArtist() { + override fun navigateToCurrentSong() { + playbackModel.song.value?.let(detailModel::showAlbum) + } + + override fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } - private fun navigateToCurrentAlbum() { + override fun navigateToCurrentAlbum() { playbackModel.song.value?.let { detailModel.showAlbum(it.album) } } + + override fun navigateToMenu() { + // TODO + } + + private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : + OnPageChangeCallback() { + + private var targetPosition = RecyclerView.NO_POSITION + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + targetPosition = position + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE && + targetPosition != RecyclerView.NO_POSITION && + targetPosition != queueViewModel.index.value) { + queueViewModel.goto(targetPosition) + } + } + } + + private companion object { + val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt new file mode 100644 index 000000000..d0f176231 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaybackPagerAdapter.kt is part of Auxio. + * + * 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.ui + +import android.view.ViewGroup +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlin.jvm.internal.Intrinsics +import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.inflater + +/** @author Koitharu, Alexander Capehart (OxygenCobalt) */ +class PlaybackPagerAdapter(private val listener: Listener) : + FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { + return CoverViewHolder.from(parent) + } + + override fun onBindViewHolder(holder: CoverViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } + + override fun onViewRecycled(holder: CoverViewHolder) { + holder.recycle() + super.onViewRecycled(holder) + } + + interface Listener { + fun navigateToCurrentArtist() + + fun navigateToCurrentAlbum() + + fun navigateToCurrentSong() + + fun navigateToMenu() + } +} + +class CoverViewHolder private constructor(private val binding: ItemPlaybackSongBinding) : + RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver { + init { + binding.root.layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) + } + + /** + * Bind new data to this instance. + * + * @param item The new [Song] to bind. + */ + fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) { + val context = binding.root.context + binding.playbackCover.bind(item) + // binding.playbackCover.bind(item) + binding.playbackSong.apply { + text = item.name.resolve(context) + setOnClickListener { listener.navigateToCurrentSong() } + } + binding.playbackArtist.apply { + text = item.artists.resolveNames(context) + setOnClickListener { listener.navigateToCurrentArtist() } + } + binding.playbackAlbum.apply { + text = item.album.name.resolve(context) + setOnClickListener { listener.navigateToCurrentAlbum() } + } + setSelected(true) + } + + fun recycle() { + // Marquee elements leak if they are not disabled when the views are destroyed. + // TODO: Move to TextView impl to avoid having to deal with lifecycle here + setSelected(false) + } + + private fun setSelected(value: Boolean) { + binding.playbackSong.isSelected = value + binding.playbackArtist.isSelected = value + binding.playbackAlbum.isSelected = value + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup) = + CoverViewHolder(ItemPlaybackSongBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.uid == newItem.uid + + override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } + } +} diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index e68de3423..28f69b2b2 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout-h480dp/item_playback_song.xml b/app/src/main/res/layout-h480dp/item_playback_song.xml new file mode 100644 index 000000000..9ce0bcf47 --- /dev/null +++ b/app/src/main/res/layout-h480dp/item_playback_song.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index eecb65e6e..b7ead10f6 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout-sw600dp/item_playback_song.xml b/app/src/main/res/layout-sw600dp/item_playback_song.xml new file mode 100644 index 000000000..9ce0bcf47 --- /dev/null +++ b/app/src/main/res/layout-sw600dp/item_playback_song.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 857cb7545..e873722e1 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -16,64 +16,22 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - - - - - - - @@ -159,4 +117,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_playback_song.xml b/app/src/main/res/layout/item_playback_song.xml new file mode 100644 index 000000000..3e8c0c6a1 --- /dev/null +++ b/app/src/main/res/layout/item_playback_song.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file From b4b830fbf426710eadb4ad149dacddc597a32a3d Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:05:40 -0700 Subject: [PATCH 112/127] build: separate 3.2.1 changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecfb63b5..a8cc477c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward +## 3.2.1 + #### What's Improved - Added support for native M4A multi-value tags based on duplicate atoms From bf3c30e8afaf50a60daa37f0f11af71c6d7b1e86 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:11:50 -0700 Subject: [PATCH 113/127] music: add demo release type This is part of the MusicBrainz spec, but I didn't think of implementing it. Turns out it's stupidly common among music releases, so may as well. Resolves #590. --- CHANGELOG.md | 1 + .../org/oxycblt/auxio/detail/DetailViewModel.kt | 2 ++ .../org/oxycblt/auxio/music/info/ReleaseType.kt | 13 +++++++++++++ app/src/main/res/values/strings.xml | 4 ++++ .../org/oxycblt/auxio/music/info/ReleaseTypeTest.kt | 1 + 5 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cc477c1..5173006d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ #### What's New - Added ability to rewind/skip tracks by swiping back/forward +- Added support for demo release type ## 3.2.1 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 648424458..3613d96c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -607,6 +607,7 @@ constructor( is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS is ReleaseType.Mix -> AlbumGrouping.DJMIXES is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES + is ReleaseType.Demo -> AlbumGrouping.DEMOS } } } @@ -709,6 +710,7 @@ constructor( SOUNDTRACKS(R.string.lbl_soundtracks), DJMIXES(R.string.lbl_mixes), MIXTAPES(R.string.lbl_mixtapes), + DEMOS(R.string.lbl_demos), APPEARANCES(R.string.lbl_appears_on), LIVE(R.string.lbl_live_group), REMIXES(R.string.lbl_remix_group), diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index 24260912b..3fe45b202 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -143,6 +143,18 @@ sealed interface ReleaseType { get() = R.string.lbl_mixtape } + /** + * A demo. These are usually [EP]-sized releases of music made to promote an Artist or a future + * release. + */ + data object Demo : ReleaseType { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_demo + } + /** A specification of what kind of performance a particular release is. */ enum class Refinement { /** A release consisting of a live performance */ @@ -220,6 +232,7 @@ sealed interface ReleaseType { type.equals("dj-mix", true) -> Mix type.equals("live", true) -> convertRefinement(Refinement.LIVE) type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + type.equals("demo", true) -> Demo else -> convertRefinement(null) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31700900b..5f8eed478 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,10 @@ Mixtapes Mixtape + + Demo + + Demos DJ Mixes diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt index 9ca019a40..1294e3daf 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt @@ -36,6 +36,7 @@ class ReleaseTypeTest { 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"))) + assertEquals(ReleaseType.Demo, ReleaseType.parse(listOf("album", "demo"))) } @Test From d6801354cea772c354ccb0a020fefea466650fcb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:24:06 -0700 Subject: [PATCH 114/127] music: only include explicit albums in count Only include an artists explicit albums (ones directly linked w/album artist) in their count. This is arguably more appropriate than the prior behavior, given Auxio's collaborator/artist distinction. Resolves #581. --- CHANGELOG.md | 4 ++++ .../detail/header/ArtistDetailHeaderAdapter.kt | 6 +++++- .../auxio/list/menu/MenuDialogFragmentImpl.kt | 6 +++++- .../oxycblt/auxio/list/recycler/ViewHolders.kt | 8 ++++++-- .../oxycblt/auxio/music/device/DeviceMusicImpl.kt | 15 ++++++++++++--- app/src/main/res/values/strings.xml | 1 + 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5173006d5..b4cac8eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added ability to rewind/skip tracks by swiping back/forward - Added support for demo release type +#### What's Changed +- Albums linked to an artist only as a collaborator are no longer included +in an artist's album count + ## 3.2.1 #### What's Improved diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt index cb2343219..e85c892e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt @@ -71,7 +71,11 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + if (artist.explicitAlbums.isNotEmpty()) { + binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size) + } else { + binding.context.getString(R.string.def_album_count) + }, if (artist.songs.isNotEmpty()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index a7eef2392..a7fadce1f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -178,7 +178,11 @@ class ArtistMenuDialogFragment : MenuDialogFragment() { binding.menuInfo.text = getString( R.string.fmt_two, - context.getPlural(R.plurals.fmt_album_count, menu.artist.albums.size), + if (menu.artist.explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, if (menu.artist.songs.isNotEmpty()) { context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size) } 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 c829248e5..36565b63f 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 @@ -164,7 +164,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin binding.parentInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + if (artist.explicitAlbums.isNotEmpty()) { + binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size) + } else { + binding.context.getString(R.string.def_album_count) + }, if (artist.songs.isNotEmpty()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { @@ -199,7 +203,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.name == newItem.name && - oldItem.albums.size == newItem.albums.size && + oldItem.explicitAlbums.size == newItem.explicitAlbums.size && oldItem.songs.size == newItem.songs.size } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 91a4e1702..bc5418677 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -432,7 +432,7 @@ class ArtistImpl( ?: Name.Unknown(R.string.def_artist) override val songs: Set - override val albums: Set + override val albums: Set = emptySet() override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? @@ -463,7 +463,7 @@ class ArtistImpl( } songs = distinctSongs - albums = albumMap.keys + val albums = albumMap.keys explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } durationMs = songs.sumOf { it.durationMs }.positiveOrNull() @@ -506,7 +506,16 @@ class ArtistImpl( * @return This instance upcasted to [Artist]. */ fun finalize(): Artist { - check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist $name: Empty" } + // There are valid artist configurations: + // 1. No songs, no implicit albums, some explicit albums + // 2. Some songs, no implicit albums, some explicit albums + // 3. Some songs, some implicit albums, no implicit albums + // 4. Some songs, some implicit albums, some explicit albums + // I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty, + // but I can't be 100% certain. + check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) { + "Malformed artist $name: Empty" + } genres = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f8eed478..c7866d184 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,6 +357,7 @@ No disc No track No songs + No albums No music playing From 0ad7a8955ae1dc3ed5f46b440d89cb27121f8e59 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:32:51 -0700 Subject: [PATCH 115/127] music: eliminate all reference to artists albums It's no longer used in any capacity. --- app/src/main/java/org/oxycblt/auxio/music/Music.kt | 6 ------ .../java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt | 1 - 2 files changed, 7 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 766ea462c..7d6fae73e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -317,12 +317,6 @@ interface Album : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Artist : MusicParent { - /** - * All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums]. - * Note that any [Song] credited to this artist will have it's [Album] considered to be - * "indirectly" linked to this [Artist], and thus included in this list. - */ - val albums: Collection /** Albums directly credited to this [Artist] via a "Album Artist" tag. */ val explicitAlbums: Collection /** Albums indirectly credited to this [Artist] via an "Artist" tag. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index bc5418677..e3e2232ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -432,7 +432,6 @@ class ArtistImpl( ?: Name.Unknown(R.string.def_artist) override val songs: Set - override val albums: Set = emptySet() override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? From 9ae6b20fd15ca1193dfd598202937cb22dd9e7a0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 12 Nov 2023 11:54:37 -0700 Subject: [PATCH 116/127] music: decouple settings somewhat Try to decouple the stateful music settings object from the stateless internals of the music loader. This should make unit testing far easier. --- .../oxycblt/auxio/music/MusicRepository.kt | 25 +++- .../auxio/music/device/DeviceLibrary.kt | 13 +- .../org/oxycblt/auxio/music/fs/FsModule.kt | 5 +- .../auxio/music/fs/MediaStoreExtractor.kt | 56 ++++---- .../java/org/oxycblt/auxio/music/info/Name.kt | 39 ++---- .../auxio/music/metadata/Separators.kt | 14 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 22 ++- .../org/oxycblt/auxio/music/info/NameTest.kt | 126 ++++++++---------- 8 files changed, 133 insertions(+), 167 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..55cfeaf0a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -36,6 +36,8 @@ import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.MediaStoreExtractor +import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary @@ -223,7 +225,8 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, private val deviceLibraryFactory: DeviceLibrary.Factory, - private val userLibraryFactory: UserLibrary.Factory + private val userLibraryFactory: UserLibrary.Factory, + private val musicSettings: MusicSettings ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() @@ -356,6 +359,8 @@ constructor( } private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + // TODO: Find a way to break up this monster of a method, preferably as another class. + val start = System.currentTimeMillis() // Make sure we have permissions before going forward. Theoretically this would be better // done at the UI level, but that intertwines logic and display too much. @@ -365,6 +370,17 @@ constructor( throw NoAudioPermissionException() } + // Obtain configuration information + val constraints = + MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs) + val separators = Separators.from(musicSettings.separators) + val nameFactory = + if (musicSettings.intelligentSorting) { + Name.Known.IntelligentFactory + } else { + Name.Known.SimpleFactory + } + // Begin with querying MediaStore and the music cache. The former is needed for Auxio // to figure out what songs are (probably) on the device, and the latter will be needed // for discovery (described later). These have no shared state, so they are done in @@ -376,7 +392,7 @@ constructor( worker.scope.async { val query = try { - mediaStoreExtractor.query() + mediaStoreExtractor.query(constraints) } catch (e: Exception) { // Normally, errors in an async call immediately bubble up to the Looper // and crash the app. Thus, we have to wrap any error into a Result @@ -445,7 +461,8 @@ constructor( worker.scope.async(Dispatchers.Default) { val deviceLibrary = try { - deviceLibraryFactory.create(completeSongs, processedSongs) + deviceLibraryFactory.create( + completeSongs, processedSongs, separators, nameFactory) } catch (e: Exception) { processedSongs.close(e) return@async Result.failure(e) @@ -518,7 +535,7 @@ constructor( logD("Awaiting DeviceLibrary creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() logD("Starting UserLibrary creation") - val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) + val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory) // Loading process is functionally done, indicate such logD( diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index 527dcd198..c694a65ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -28,7 +28,6 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery @@ -110,19 +109,19 @@ interface DeviceLibrary { suspend fun create( rawSongs: Channel, processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl } } -class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : - DeviceLibrary.Factory { +class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { override suspend fun create( rawSongs: Channel, - processedSongs: Channel + processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl { - val nameFactory = Name.Known.Factory.from(musicSettings) - val separators = Separators.from(musicSettings) - val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() val artistGrouping = mutableMapOf>() diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 10c4192bc..828a468da 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -24,12 +24,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import org.oxycblt.auxio.music.MusicSettings @Module @InstallIn(SingletonComponent::class) class FsModule { @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = - MediaStoreExtractor.from(context, musicSettings) + fun mediaStoreExtractor(@ApplicationContext context: Context) = + MediaStoreExtractor.from(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 76e62e897..392103d80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -29,7 +29,6 @@ import androidx.core.database.getStringOrNull import java.io.File import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.info.Date @@ -50,9 +49,11 @@ interface MediaStoreExtractor { /** * Query the media database. * + * @param constraints Configuration parameter to restrict what music should be ignored when + * querying. * @return A new [Query] returned from the media database. */ - suspend fun query(): Query + suspend fun query(constraints: Constraints): Query /** * Consume the [Cursor] loaded after [query]. @@ -84,46 +85,44 @@ interface MediaStoreExtractor { fun populateTags(rawSong: RawSong) } + data class Constraints(val excludeNonMusic: Boolean, val musicDirs: MusicDirectories) + companion object { /** * Create a framework-backed instance. * * @param context [Context] required. - * @param musicSettings [MusicSettings] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = + fun from(context: Context): MediaStoreExtractor = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - Api30MediaStoreExtractor(context, musicSettings) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29MediaStoreExtractor(context, musicSettings) - else -> Api21MediaStoreExtractor(context, musicSettings) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) + else -> Api21MediaStoreExtractor(context) } } } -private abstract class BaseMediaStoreExtractor( - protected val context: Context, - private val musicSettings: MusicSettings -) : MediaStoreExtractor { - final override suspend fun query(): MediaStoreExtractor.Query { +private abstract class BaseMediaStoreExtractor(protected val context: Context) : + MediaStoreExtractor { + final override suspend fun query( + constraints: MediaStoreExtractor.Constraints + ): MediaStoreExtractor.Query { val start = System.currentTimeMillis() val args = mutableListOf() var selector = BASE_SELECTOR // Filter out audio that is not music, if enabled. - if (musicSettings.excludeNonMusic) { + if (constraints.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 = musicSettings.musicDirs - if (dirs.dirs.isNotEmpty()) { + if (constraints.musicDirs.dirs.isNotEmpty()) { selector += " AND " - if (!dirs.shouldInclude) { + if (!constraints.musicDirs.shouldInclude) { logD("Excluding directories in selector") // Without a NOT, the query will be restricted to the specified paths, resulting // in the "Include" mode. With a NOT, the specified paths will not be included, @@ -134,10 +133,10 @@ private abstract class BaseMediaStoreExtractor( // Specifying the paths to filter is version-specific, delegate to the concrete // implementations. - for (i in dirs.dirs.indices) { - if (addDirToSelector(dirs.dirs[i], args)) { + for (i in constraints.musicDirs.dirs.indices) { + if (addDirToSelector(constraints.musicDirs.dirs[i], args)) { selector += - if (i < dirs.dirs.lastIndex) { + if (i < constraints.musicDirs.dirs.lastIndex) { "$dirSelectorTemplate OR " } else { dirSelectorTemplate @@ -362,8 +361,7 @@ private abstract class BaseMediaStoreExtractor( // Note: The separation between version-specific backends may not be the cleanest. To preserve // speed, we only want to add redundancy on known issues, not with possible issues. -private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseMediaStoreExtractor(context, musicSettings) { +private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -447,10 +445,8 @@ private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private abstract class BaseApi29MediaStoreExtractor( - context: Context, - musicSettings: MusicSettings -) : BaseMediaStoreExtractor(context, musicSettings) { +private abstract class BaseApi29MediaStoreExtractor(context: Context) : + BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -512,8 +508,7 @@ private abstract class BaseApi29MediaStoreExtractor( * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) @@ -553,8 +548,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index 09f4d8035..30626f01e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -23,12 +23,11 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import java.text.CollationKey import java.text.Collator -import org.oxycblt.auxio.music.MusicSettings /** * The name of a music item. * - * This class automatically implements + * This class automatically implements advanced sorting heuristics for music naming, * * @author Alexander Capehart */ @@ -80,7 +79,7 @@ sealed interface Name : Comparable { is Unknown -> 1 } - interface Factory { + sealed interface Factory { /** * Create a new instance of [Name.Known] * @@ -88,22 +87,16 @@ sealed interface Name : Comparable { * @param sort The raw sort name obtained from the music item */ fun parse(raw: String, sort: String?): Known + } - companion object { - /** - * Creates a new instance from the **current state** of the given [MusicSettings]'s - * user-defined name configuration. - * - * @param settings The [MusicSettings] to use. - * @return A [Factory] instance reflecting the configuration state. - */ - fun from(settings: MusicSettings) = - if (settings.intelligentSorting) { - IntelligentKnownName.Factory - } else { - SimpleKnownName.Factory - } - } + /** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */ + data object SimpleFactory : Factory { + override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) + } + + /** Produces an intelligent [Known] with advanced, but more fragile heuristics. */ + data object IntelligentFactory : Factory { + override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) } } @@ -137,7 +130,6 @@ private val punctRegex by lazy { Regex("[\\p{Punct}+]") } * * @author Alexander Capehart (OxygenCobalt) */ -@VisibleForTesting data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = listOf(parseToken(sort ?: raw)) @@ -148,10 +140,6 @@ data class SimpleKnownName(override val raw: String, override val sort: String?) // Always use lexicographic mode since we aren't parsing any numeric components return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) } - - data object Factory : Name.Known.Factory { - override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) - } } /** @@ -159,7 +147,6 @@ data class SimpleKnownName(override val raw: String, override val sort: String?) * * @author Alexander Capehart (OxygenCobalt) */ -@VisibleForTesting data class IntelligentKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = parseTokens(sort ?: raw) @@ -208,10 +195,6 @@ data class IntelligentKnownName(override val raw: String, override val sort: Str } } - data object Factory : Name.Known.Factory { - override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) - } - companion object { private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 8d2740e74..678e1ef2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -18,9 +18,6 @@ package org.oxycblt.auxio.music.metadata -import androidx.annotation.VisibleForTesting -import org.oxycblt.auxio.music.MusicSettings - /** * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags * that may be delimited with a separator character. @@ -45,15 +42,12 @@ interface Separators { const val AND = '&' /** - * Creates a new instance from the **current state** of the given [MusicSettings]'s - * user-defined separator configuration. + * Creates a new instance from a string of separator characters to use. * - * @param settings The [MusicSettings] to use. - * @return A new [Separators] instance reflecting the configuration state. + * @param chars The separator characters to use. Each character in the string will be + * checked for when splitting a string list. + * @return A new [Separators] instance reflecting the separators. */ - fun from(settings: MusicSettings) = from(settings.separators) - - @VisibleForTesting fun from(chars: String) = if (chars.isNotEmpty()) { CharSeparators(chars.toSet()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index faae9594b..70943b7d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -22,7 +22,6 @@ import java.lang.Exception import javax.inject.Inject import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary @@ -82,7 +81,8 @@ interface UserLibrary { */ suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary } } @@ -139,9 +139,7 @@ interface MutableUserLibrary : UserLibrary { suspend fun rewritePlaylist(playlist: Playlist, songs: List): Boolean } -class UserLibraryFactoryImpl -@Inject -constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : +class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao) : UserLibrary.Factory { override suspend fun query() = try { @@ -155,22 +153,22 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus override suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary { - val nameFactory = Name.Known.Factory.from(musicSettings) val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory) playlistMap[playlistImpl.uid] = playlistImpl } - return UserLibraryImpl(playlistDao, playlistMap, musicSettings) + return UserLibraryImpl(playlistDao, playlistMap, nameFactory) } } private class UserLibraryImpl( private val playlistDao: PlaylistDao, private val playlistMap: MutableMap, - private val musicSettings: MusicSettings + private val nameFactory: Name.Known.Factory ) : MutableUserLibrary { override fun hashCode() = playlistMap.hashCode() @@ -186,7 +184,7 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override suspend fun createPlaylist(name: String, songs: List): Playlist? { - val playlistImpl = PlaylistImpl.from(name, songs, Name.Known.Factory.from(musicSettings)) + val playlistImpl = PlaylistImpl.from(name, songs, nameFactory) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( @@ -209,9 +207,7 @@ private class UserLibraryImpl( val playlistImpl = synchronized(this) { requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - .also { - playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings)) - } + .also { playlistMap[it.uid] = it.edit(name, nameFactory) } } return try { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index fd80d51c4..078a1f154 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -18,30 +18,14 @@ package org.oxycblt.auxio.music.info -import io.mockk.every -import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue import org.junit.Test -import org.oxycblt.auxio.music.MusicSettings class NameTest { - @Test - fun name_simple_from_settings() { - val musicSettings = mockk { every { intelligentSorting } returns false } - assertTrue(Name.Known.Factory.from(musicSettings) is SimpleKnownName.Factory) - } - - @Test - fun name_intelligent_from_settings() { - val musicSettings = mockk { every { intelligentSorting } returns true } - assertTrue(Name.Known.Factory.from(musicSettings) is IntelligentKnownName.Factory) - } - @Test fun name_simple_withoutPunct() { - val name = SimpleKnownName("Loveless", null) + val name = Name.Known.SimpleFactory.parse("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) @@ -52,7 +36,7 @@ class NameTest { @Test fun name_simple_withPunct() { - val name = SimpleKnownName("alt-J", null) + val name = Name.Known.SimpleFactory.parse("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) @@ -63,7 +47,7 @@ class NameTest { @Test fun name_simple_oopsAllPunct() { - val name = SimpleKnownName("!!!", null) + val name = Name.Known.SimpleFactory.parse("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) @@ -74,7 +58,7 @@ class NameTest { @Test fun name_simple_spacedPunct() { - val name = SimpleKnownName("& Yet & Yet", null) + val name = Name.Known.SimpleFactory.parse("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) @@ -85,7 +69,7 @@ class NameTest { @Test fun name_simple_withSort() { - val name = SimpleKnownName("The Smile", "Smile") + val name = Name.Known.SimpleFactory.parse("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) @@ -96,7 +80,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("Loveless", null) + val name = Name.Known.IntelligentFactory.parse("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) @@ -107,7 +91,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { - val name = IntelligentKnownName("15 Step", null) + val name = Name.Known.IntelligentFactory.parse("15 Step", null) assertEquals("15 Step", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -121,7 +105,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { - val name = IntelligentKnownName("23Kid", null) + val name = Name.Known.IntelligentFactory.parse("23Kid", null) assertEquals("23Kid", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -135,7 +119,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { - val name = IntelligentKnownName("Foo 1 2 Bar", null) + val name = Name.Known.IntelligentFactory.parse("Foo 1 2 Bar", null) assertEquals("Foo 1 2 Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -158,7 +142,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { - val name = IntelligentKnownName("Foo12Bar", null) + val name = Name.Known.IntelligentFactory.parse("Foo12Bar", null) assertEquals("Foo12Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -175,7 +159,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { - val name = IntelligentKnownName("Foo 1", null) + val name = Name.Known.IntelligentFactory.parse("Foo 1", null) assertEquals("Foo 1", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) @@ -189,7 +173,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { - val name = IntelligentKnownName("Error404", null) + val name = Name.Known.IntelligentFactory.parse("Error404", null) assertEquals("Error404", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) @@ -203,7 +187,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withThe_withoutNumerics() { - val name = IntelligentKnownName("The National Anthem", null) + val name = Name.Known.IntelligentFactory.parse("The National Anthem", null) assertEquals("The National Anthem", name.raw) assertEquals(null, name.sort) assertEquals("N", name.thumb) @@ -214,7 +198,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withAn_withoutNumerics() { - val name = IntelligentKnownName("An Eagle in Your Mind", null) + val name = Name.Known.IntelligentFactory.parse("An Eagle in Your Mind", null) assertEquals("An Eagle in Your Mind", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) @@ -225,7 +209,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_withA_withoutNumerics() { - val name = IntelligentKnownName("A Song For Our Fathers", null) + val name = Name.Known.IntelligentFactory.parse("A Song For Our Fathers", null) assertEquals("A Song For Our Fathers", name.raw) assertEquals(null, name.sort) assertEquals("S", name.thumb) @@ -236,7 +220,7 @@ class NameTest { @Test fun name_intelligent_withPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("alt-J", null) + val name = Name.Known.IntelligentFactory.parse("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) @@ -247,7 +231,7 @@ class NameTest { @Test fun name_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("!!!", null) + val name = Name.Known.IntelligentFactory.parse("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) @@ -258,7 +242,7 @@ class NameTest { @Test fun name_intelligent_withoutPunct_shortArticle_withNumerics() { - val name = IntelligentKnownName("the 1", null) + val name = Name.Known.IntelligentFactory.parse("the 1", null) assertEquals("the 1", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -269,7 +253,7 @@ class NameTest { @Test fun name_intelligent_spacedPunct_withoutArticle_withoutNumerics() { - val name = IntelligentKnownName("& Yet & Yet", null) + val name = Name.Known.IntelligentFactory.parse("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) @@ -280,7 +264,7 @@ class NameTest { @Test fun name_intelligent_withPunct_withoutArticle_withNumerics() { - val name = IntelligentKnownName("Design : 2 : 3", null) + val name = Name.Known.IntelligentFactory.parse("Design : 2 : 3", null) assertEquals("Design : 2 : 3", name.raw) assertEquals(null, name.sort) assertEquals("D", name.thumb) @@ -300,7 +284,7 @@ class NameTest { @Test fun name_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { - val name = IntelligentKnownName("2 + 2 = 5", null) + val name = Name.Known.IntelligentFactory.parse("2 + 2 = 5", null) assertEquals("2 + 2 = 5", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) @@ -323,7 +307,7 @@ class NameTest { @Test fun name_intelligent_withSort() { - val name = IntelligentKnownName("The Smile", "Smile") + val name = Name.Known.IntelligentFactory.parse("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) @@ -334,40 +318,40 @@ class NameTest { @Test fun name_equals_simple() { - val a = SimpleKnownName("The Same", "Same") - val b = SimpleKnownName("The Same", "Same") + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", "Same") assertEquals(a, b) } @Test fun name_equals_differentSort() { - val a = SimpleKnownName("The Same", "Same") - val b = SimpleKnownName("The Same", null) + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", null) assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_equals_intelligent_differentTokens() { - val a = IntelligentKnownName("The Same", "Same") - val b = IntelligentKnownName("Same", "Same") + val a = Name.Known.IntelligentFactory.parse("The Same", "Same") + val b = Name.Known.IntelligentFactory.parse("Same", "Same") assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { - val a = SimpleKnownName("A", null) - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", null) + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { - val a = SimpleKnownName("A Brain in a Bottle", null) - val b = SimpleKnownName("Acid Rain", null) - val c = SimpleKnownName("Boralis / Contrastellar", null) - val d = SimpleKnownName("Breathe In", null) + val a = Name.Known.SimpleFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.SimpleFactory.parse("Acid Rain", null) + val c = Name.Known.SimpleFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.SimpleFactory.parse("Breathe In", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -375,40 +359,40 @@ class NameTest { @Test fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { - val a = SimpleKnownName("15 Step", null) - val b = SimpleKnownName("128 Harps", null) - val c = SimpleKnownName("1969", null) + val a = Name.Known.SimpleFactory.parse("15 Step", null) + val b = Name.Known.SimpleFactory.parse("128 Harps", null) + val c = Name.Known.SimpleFactory.parse("1969", null) assertEquals(1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) } @Test fun name_compareTo_simple_withPartialSort() { - val a = SimpleKnownName("A", "C") - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_simple_withSort() { - val a = SimpleKnownName("D", "A") - val b = SimpleKnownName("C", "B") + val a = Name.Known.SimpleFactory.parse("D", "A") + val b = Name.Known.SimpleFactory.parse("C", "B") assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { - val a = IntelligentKnownName("A", null) - val b = IntelligentKnownName("B", null) + val a = Name.Known.IntelligentFactory.parse("A", null) + val b = Name.Known.IntelligentFactory.parse("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { - val a = IntelligentKnownName("A Brain in a Bottle", null) - val b = IntelligentKnownName("Acid Rain", null) - val c = IntelligentKnownName("Boralis / Contrastellar", null) - val d = IntelligentKnownName("Breathe In", null) + val a = Name.Known.IntelligentFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.IntelligentFactory.parse("Acid Rain", null) + val c = Name.Known.IntelligentFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.IntelligentFactory.parse("Breathe In", null) assertEquals(1, a.compareTo(b)) assertEquals(1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -416,9 +400,9 @@ class NameTest { @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { - val a = IntelligentKnownName("15 Step", null) - val b = IntelligentKnownName("128 Harps", null) - val c = IntelligentKnownName("1969", null) + val a = Name.Known.IntelligentFactory.parse("15 Step", null) + val b = Name.Known.IntelligentFactory.parse("128 Harps", null) + val c = Name.Known.IntelligentFactory.parse("1969", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, b.compareTo(c)) assertEquals(-2, a.compareTo(c)) @@ -426,15 +410,15 @@ class NameTest { @Test fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { - val a = SimpleKnownName("A", "C") - val b = SimpleKnownName("B", null) + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { - val a = IntelligentKnownName("D", "A") - val b = IntelligentKnownName("C", "B") + val a = Name.Known.IntelligentFactory.parse("D", "A") + val b = Name.Known.IntelligentFactory.parse("C", "B") assertEquals(-1, a.compareTo(b)) } @@ -447,7 +431,7 @@ class NameTest { @Test fun name_compareTo_mixed() { val a = Name.Unknown(0) - val b = IntelligentKnownName("A", null) + val b = Name.Known.IntelligentFactory.parse("A", null) assertEquals(-1, a.compareTo(b)) } } From 5204b591148c19ed47422b38f516857cd8b39e5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 26 Nov 2023 14:56:55 -0700 Subject: [PATCH 117/127] info: add android 14 to bug template --- .github/ISSUE_TEMPLATE/bug-crash-report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 652dba0b8..7b94b9916 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -34,6 +34,7 @@ body: attributes: label: What android version do you use? options: + - Android 14 - Android 13 - Android 12L - Android 12 From 7d9ed7d114ddba509b5f978438378b43eaeeb186 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 3 Dec 2023 19:48:58 +0100 Subject: [PATCH 118/127] Translations update from Hosted Weblate (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Spanish) Currently translated at 100.0% (289 of 289 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Spanish) Currently translated at 99.3% (290 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Czech) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Czech) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hans/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/ * Translated using Weblate (Hindi) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hant/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pa/ * Translated using Weblate (Hindi) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hi/ * Translated using Weblate (Russian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Russian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ru/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/be/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 19.1% (56 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (292 of 292 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (German) Currently translated at 100.0% (38 of 38 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ --------- Co-authored-by: Iago Co-authored-by: gallegonovato Co-authored-by: Fjuro Co-authored-by: BMT[UA] Co-authored-by: Eric Co-authored-by: Vaclovas Intas Co-authored-by: ShareASmile Co-authored-by: abc0922001 Co-authored-by: Макар Разин Co-authored-by: qwerty287 --- app/src/main/res/values-be/strings.xml | 3 + app/src/main/res/values-cs/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 67 ++++++++++--------- app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-lt/strings.xml | 3 + app/src/main/res/values-pa/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 1 + .../metadata/android/be/full_description.txt | 2 +- .../metadata/android/cs/full_description.txt | 2 +- .../metadata/android/de/full_description.txt | 2 +- .../android/es-ES/full_description.txt | 2 +- .../metadata/android/hi/full_description.txt | 2 +- .../metadata/android/lt/full_description.txt | 4 +- .../metadata/android/pa/full_description.txt | 2 +- .../metadata/android/ru/full_description.txt | 2 +- .../metadata/android/uk/full_description.txt | 2 +- .../android/zh-CN/full_description.txt | 2 +- .../android/zh-Hant/full_description.txt | 22 ++++++ 22 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 fastlane/metadata/android/zh-Hant/full_description.txt diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 0084fbd04..a17118fb0 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -304,4 +304,7 @@ Скапіравана Інфармацыя пра памылку Справаздача пра памылку + Няма альбомаў + Дэма + Дэманстрацыі \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3a2ef1255..e39a84ca3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -315,4 +315,7 @@ Informace o chybě Zkopírovat Nahlásit + Žádná alba + Demo + Dema \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 35e2abb16..4c0fa5b55 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -306,4 +306,7 @@ Kopiert Melden Fehlerinformation + Keine Alben + Demo + Demos \ 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 a4a109704..20af977ec 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -14,7 +14,7 @@ Buscar Filtrar Todo - Organizar + Ordenar Nombre Artista Álbum @@ -43,7 +43,7 @@ Desarrollado por Alexander Capehart Ajustes - Aspecto y sensación + Aspecto y Comportamiento Tema Automático Claro @@ -52,31 +52,31 @@ Tema negro Usar un tema completamente negro Pantalla - Pestañas de biblioteca + Pestañas de la biblioteca Cambiar visibilidad y orden de las pestañas de la biblioteca Carátulas redondeadas - Habilite las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) - Usar acciones de notificación alternativas + Habilitar las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) + Usar acciones de notificación personalizadas Sonido Estrategia de la ganancia de la repetición - Por pista - Por álbum + Preferir pista + Preferir álbum Preferir el álbum si se está en reproducción - Comportamiento + Personalizar Cuando se está reproduciendo de la biblioteca Recordar mezcla - Mantener mezcla cuando se reproduce una nueva canción - Rebobinar atrás - Rebobinar al saltar a la canción anterior - Pausa en repetición - Pausa cuando se repite una canción + Mantener mezcla activada cuando se reproduce una nueva canción + Rebobinar antes de saltar al anterior + Rebobinar antes de saltar a la canción anterior + Pausar al repetir + Pausar cuando se repite una canción Contenido Guardar estado de reproducción - Guardar el estado de reproduccion ahora + Guardar el estado de reproducción ahora Actualizar música Recargar la biblioteca musical, utilizando las etiquetas en caché cuando sea posible - Sin música + No se ha encontrado música Falló la carga de música Auxio necesita permiso para leer su biblioteca de música No se encontró ninguna aplicación que pueda manejar esta tarea @@ -89,12 +89,12 @@ Saltar a la siguiente canción Saltar a la última canción Cambiar modo de repetición - Act/des mezcla - Mezclar todo + Activar o desactivar mezcla + Mezclar todas las canciones Quitar canción de la cola Mover canción en la cola Mover pestaña - Borrar historial de búsqueda + Borrar búsqueda Quitar carpeta Icono de Auxio Carátula de álbum @@ -177,11 +177,11 @@ Frecuencia de muestreo Cancelar Reproducción automática con auriculares - Reestablecer el estado de reproducción - Reestablecer el estado de reproducción guardado previamente (si existe) + Restablecer el estado de reproducción + Restablecer el estado de reproducción guardado previamente (si existe) Carpetas de música Gestionar de dónde se cargará la música - La músicasolo se cargará de las carpetas que añadas. + La música solo se cargará de las carpetas que añadas. Dinámico Disco %d Reproducción extendidas (EPs) @@ -193,9 +193,9 @@ Pistas de audio Mixtapes (recopilación de canciones) Mixtape (recopilación de canciones) - Remezcla + Remezclas Nombre de archivo - Siempre empezar la reproducción cuando se conectan unos auriculares (puede no funcionar en todos los dispositivos) + Siempre empezar la reproducción cuando se conecten auriculares (puede no funcionar en todos los dispositivos) Pre-amp ReplayGain El pre-amp se aplica al ajuste existente durante la reproducción Ajuste con etiquetas @@ -206,7 +206,7 @@ Álbum en directo Single en directo Compilación - Directo + En directo Audio MPEG-1 Audio MPEG-4 %d kbps @@ -214,7 +214,7 @@ EP en directo Single remix Compilaciones - EP remix + EP de remixes Directorio superior Eliminar el estado de reproducción guardado previamente (si existe) Abrir la cola @@ -222,15 +222,15 @@ Estado limpiado Limpiar el estado de reproducción Separadores de varios valores - Excluye la música + Excluye los archivos que no sean música Configurar caracteres que denotan múltiples valores de la etiqueta Coma (,) Punto y coma (;) Barra oblicua (/) Recopilación en directo - Compilaciones de remezclas - Mezclas del DJ - Mezclas del DJ + Compilación de remezclas + Mezclas de DJ + Mezcla de DJ Ecualizador Portadas de álbumes Apagado @@ -251,7 +251,7 @@ %d artistas %d artistas - Imposible guardar el estado + No se pudo guardar el estado No se puede borrar el estado Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo) Volver a escanear la música @@ -297,11 +297,11 @@ Aparece en Compartir Sin disco - Carátula del álbum Force Square + Forzar carátulas de álbum cuadradas Recorta todas las portadas de los álbumes a una relación de aspecto 1:1 Canción Vista - Reproducir la canción por tí mismo + Reproducir la canción por sí misma Ordenar por Dirección Selección de imágenes @@ -310,4 +310,7 @@ Información sobre el error Copiado Informar + Sin álbumes + Demostración + Demostraciones \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 0384924fd..84be232d8 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -305,4 +305,7 @@ रिपोर्ट करें कापी किया गया और + कोई एल्बम नहीं + डेमो + डेमो \ 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 0b2138093..94c41de55 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -304,4 +304,7 @@ Nukopijuota Daugiau Pranešti + Nėra albumų + Demo + Demos \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 1d385c7bc..c33f1e852 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -298,4 +298,7 @@ ਤਰੁੱਟੀ ਦੀ ਜਾਣਕਾਰੀ ਕਾਪੀ ਕੀਤਾ ਗਿਆ ਰਿਪੋਰਟ ਕਰੋ + ਕੋਈ ਐਲਬਮ ਨਹੀਂ + ਡੈਮੋ + ਡੈਮੋ \ 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 2dc979122..4859680ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -313,4 +313,7 @@ Информация об ошибке Отчёт об ошибке Скопировано + Няма альбомаў + Демо + Дэманстрацыі \ 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 f4b12a5aa..2164136ed 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -310,4 +310,7 @@ Інформація про помилку Скопійовано Звіт + Альбомів немає + Демо + Демонстрації \ 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 51381f724..f3742f1d0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -304,4 +304,7 @@ 更多 已复制 错误信息 + 无专辑 + 演示 + 样曲 \ 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 b46cd1c4a..c59791d5c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -72,4 +72,5 @@ 專輯 單曲 單曲 + 在更多的用戶界面元素上啟用圓角(需要專輯封面也要設定圓角) \ No newline at end of file diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt index 25badc179..dc3182282 100644 --- a/fastlane/metadata/android/be/full_description.txt +++ b/fastlane/metadata/android/be/full_description.txt @@ -19,4 +19,4 @@ Auxio - гэта мясцовы музычны плэер з хуткім і н - Аўтазапуск гарнітуры - Стыльныя віджэты, якія аўтаматычна адаптуюцца да іх памеру - Цалкам прыватны і ў аўтаномным рэжыме -- Ніякіх круглявых вокладак альбомаў (Калі вы не хочаце іх. Тады вы можаце.) +- Ніякіх круглявых вокладак альбомаў (па змаўчанні) diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index 26cabb0d0..caf9e51ef 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -20,4 +20,4 @@ přesná/původní data, štítky pro řazení a další - Automatické přehrávání při připojení sluchátek - Stylové widgety, které se automaticky adaptují své velikosti - Plně soukromý a offline -- Žádné zakulacené obaly alb (Pokud je tedy nechcete. Jinak jsou k dispozici.) +- Žádné zakulacené obaly alb (ve výchozím nastavení) diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 00b49f827..3eedd16a2 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -20,4 +20,4 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab - Autoplay bei Kopfhörern - Stylische Widgets, die ihre Größe anpassen - vollständig privat und offline -- keine abgerundeten Album-Cover (Außer die willst. Dann geht das.) +- keine abgerundeten Album-Cover (standardmäßig) diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index fca66cb77..67ee646c7 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -20,4 +20,4 @@ fechas precisas/originales, ordenar etiquetas y más - Reproducción automática de auriculares - Widgets con estilo que se adaptan automáticamente a su tamaño - Completamente privado y fuera de línea -- No hay portadas de álbumes redondeadas (a menos que las quieras. Entonces puedes) +- Sin carátulas redondeadas (por defecto) diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt index 2c5fdb04a..81b86358b 100644 --- a/fastlane/metadata/android/hi/full_description.txt +++ b/fastlane/metadata/android/hi/full_description.txt @@ -20,4 +20,4 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक - हेडसेट ऑटोप्ले - स्टाइलिश विजेट जो स्वचालित रूप से अपने आकार के अनुकूल हो जाते हैं - पूरी तरह से निजी और ऑफ़लाइन -- कोई गोलाकार एल्बम कवर नहीं (जब तक आप उन्हें नहीं चाहते। फिर तुम कर सकते हो।) +- कोई गोलाकार एल्बम कवर नहीं (डिफ़ॉल्ट तौर पर) diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt index 372e28de5..043666dfc 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 iš šiuolaikinių medijos grojimo bibliotekų, 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 iš šiuolaikinių medijos grojimo bibliotekų, Auxio turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias Android funkcijas. Trumpai tariant, jame groja muziką. Funkcijos @@ -20,4 +20,4 @@ tikslias/originalias datas, rūšiavimo žymas ir dar daugiau - Automatinis ausinių grojimas - Stilingi valdikliai, kurie automatiškai prisitaiko prie savo dydžio - Visiškai privatus ir neprisijungęs -- Jokių suapvalintų albumų viršelių (Nebent nori. Tada gali.) +- Jokių suapvalintų albumų viršelių (pagal numatytuosius nustatymus) diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt index c98396ffa..c896207aa 100644 --- a/fastlane/metadata/android/pa/full_description.txt +++ b/fastlane/metadata/android/pa/full_description.txt @@ -20,4 +20,4 @@ Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱ - ਹੈੱਡਸੈੱਟ ਆਟੋਪਲੇ - ਸਟਾਈਲਿਸ਼ ਵਿਜੇਟਸ ਜੋ ਆਪਣੇ ਆਪ ਉਹਨਾਂ ਦੇ ਆਕਾਰ ਦੇ ਅਨੁਕੂਲ ਬਣਦੇ ਹਨ - ਪੂਰੀ ਤਰ੍ਹਾਂ ਨਿੱਜੀ ਅਤੇ ਆਫਲਾਈਨ -- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਜਦੋਂ ਤੱਕ ਤੁਸੀਂ ਉਹਨਾਂ ਨੂੰ ਨਹੀਂ ਚਾਹੁੰਦੇ ਹੋ। ਤੁਸੀਂ ਕਰ ਸਕਦੇ ਹੋ।) +- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਡਿਫ਼ਾਲਟ ਤੌਰ ਤੇ) diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt index c58195709..634f1e263 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/fastlane/metadata/android/ru/full_description.txt @@ -19,4 +19,4 @@ Auxio — это локальный музыкальный плеер с быс - Автоматическое воспроизведение в наушниках - Адаптивные виджеты - Полностью частный и офлайн -- Никаких закруглённых обложек альбомов (если вы их не хотите) +- Никаких закруглённых обложек альбомов (по умолчанию) diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index afbd7ac2c..890722b67 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -20,4 +20,4 @@ Auxio – це локальний музичний плеєр зі швидки - Автоматичне відтворення в навушниках - Стильні віджети, які автоматично підлаштовуються під розмір - Повністю приватний і офлайн -- Жодних заокруглених обкладинок альбомів (якщо ви їх не хочете) +- Жодних заокруглених обкладинок альбомів (за замовчуванням) diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 25b518644..318031e5c 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -20,4 +20,4 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 耳机连接时自动播放 - 按桌面尺寸自适应的风格化微件 - 完全离线且私密 -- 没有圆角的专辑封面(如果你想要也可以拥有) +- 没有圆角的专辑封面(默认设置) diff --git a/fastlane/metadata/android/zh-Hant/full_description.txt b/fastlane/metadata/android/zh-Hant/full_description.txt new file mode 100644 index 000000000..26596541a --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/full_description.txt @@ -0,0 +1,22 @@ +Auxio 是一款本機音樂播放器,擁有快速且可靠的 UI/UX,不含其他音樂播放器中許多無用的功能。Auxio 基於現代媒體播放庫構建,與使用過時 Android 功能的其他應用相比,擁有更優越的庫支援和聆聽品質。簡而言之,它播放音樂。 + +功能 + +- 基於 Media3 ExoPlayer 的播放功能 +- 源自最新 Material Design 指南的靈敏 UI +- 優化 UX,重視易用性高於邊緣情況 +- 可自訂的行為 +- 支援碟數、多位藝術家、發行類型、精確/原始日期、排序標籤等等 +- 進階藝術家系統,統一藝術家與專輯藝術家 +- 支援 SD 卡的資料夾管理 +- 可靠的播放列表功能 +- 播放狀態持久性 +- 完整的 ReplayGain 支援(適用於 MP3、FLAC、OGG、OPUS 和 MP4 檔案) +- 外部均衡器支援(例如 Wavelet) +- 無邊界設計 +- 內嵌封面支援 +- 搜尋功能 +- 耳機自動播放 +- 時尚的小工具,自動適應大小 +- 完全私密且離線 +- 默認不使用圓角專輯封面 From b1c48f13fd9c51160e325fb4bd66b32affb8cf03 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 11:02:39 -0700 Subject: [PATCH 119/127] build: update agp AGP -> 8.2.0 Requires me to enable Java 8 desugaring for some...reason. --- app/build.gradle | 2 ++ build.gradle | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index f6a0d3abc..f2684c848 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -127,6 +128,7 @@ dependencies { // Exoplayer (Vendored) implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" // Image loading implementation 'io.coil-kt:coil-base:2.4.0' diff --git a/build.gradle b/build.gradle index e2f1717dc..fdb033f43 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } plugins { - id "com.android.application" version '8.1.2' apply false + id "com.android.application" version '8.2.0' apply false id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false From 7a90e7eef153414ea446d0c494ff83185e6af10a Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 13:51:49 -0700 Subject: [PATCH 120/127] build: update deps Will need to put some work into updating some others --- app/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f2684c848..9298a778a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -88,8 +88,8 @@ dependencies { // General implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.activity:activity-ktx:1.8.0" - implementation "androidx.fragment:fragment-ktx:1.6.1" + implementation "androidx.activity:activity-ktx:1.8.2" + implementation "androidx.fragment:fragment-ktx:1.6.2" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -112,13 +112,13 @@ dependencies { implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version" // Media - implementation "androidx.media:media:1.6.0" + implementation "androidx.media:media:1.7.0" // Preferences implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-rc01' + def room_version = '2.6.1' implementation "androidx.room:room-runtime:$room_version" ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" From 4421d6cf36ab7c035d8b71515a9bf2942426f380 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 13:52:36 -0700 Subject: [PATCH 121/127] music: deduplicate by case At some point, the switch to keying raw music information broke my mitigation for duplicate tags that use similar cases. This then crashed the music loader in certain cases. Fix it by making the check use raw music keys. Resolves #614 --- .../oxycblt/auxio/music/device/DeviceMusicImpl.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index e3e2232ad..ec71efbf4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -150,26 +150,26 @@ class SongImpl( val artistSortNames = separators.split(rawSong.artistSortNames) val rawIndividualArtists = artistNames - .mapIndexedTo(mutableSetOf()) { i, name -> + .mapIndexed { i, name -> RawArtist( artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, artistSortNames.getOrNull(i)) } - .toList() + .distinctBy { it.key } val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) val albumArtistNames = separators.split(rawSong.albumArtistNames) val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) val rawAlbumArtists = albumArtistNames - .mapIndexedTo(mutableSetOf()) { i, name -> + .mapIndexed { i, name -> RawArtist( albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), name, albumArtistSortNames.getOrNull(i)) } - .toList() + .distinctBy { it.key } rawAlbum = RawAlbum( @@ -195,10 +195,7 @@ class SongImpl( val genreNames = (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) rawGenres = - genreNames - .mapTo(mutableSetOf()) { RawGenre(it) } - .toList() - .ifEmpty { listOf(RawGenre()) } + genreNames.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) } hashCode = 31 * hashCode + rawSong.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() From 953b92108a6cc3a4b4271df541945a483b1354dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:20:52 -0700 Subject: [PATCH 122/127] build: update to ndk r26 Builds, unsure if it will cause any signifigant changes. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 9298a778a..bf925d562 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion = "23.2.8568313" + ndkVersion "26.1.10909125" namespace "org.oxycblt.auxio" defaultConfig { From f4db2fcd80e7daa7a226bfef4e1b054b86e55c5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:26:52 -0700 Subject: [PATCH 123/127] build: use ndk r25 Apparently GH actions doesn't like NDK r26 yet. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index bf925d562..e49192e56 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion "26.1.10909125" + ndkVersion "25.2.9519653" namespace "org.oxycblt.auxio" defaultConfig { From b9bcdf4a515f10174b17e51b474cdf9e883488ea Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 14:33:17 -0700 Subject: [PATCH 124/127] build: fix ndk --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index e49192e56..f414337b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,7 +16,7 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion "25.2.9519653" + ndkVersion = "25.2.9519653" namespace "org.oxycblt.auxio" defaultConfig { From b7f33622e721936558ef3b20b710884b641d54ad Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 15:17:40 -0700 Subject: [PATCH 125/127] build: update media3 --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 40c3e5c68..0d4e1098a 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 40c3e5c68cbdf8758037aa40b4071cca8a53ee89 +Subproject commit 0d4e1098a8787c1db9b3ac1d8f3f5b861735b837 From bf1cbad1da3200037d91986b993d41f10315b37b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 15:44:59 -0700 Subject: [PATCH 126/127] build: update media --- media | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/media b/media index 0d4e1098a..2cfefb8f3 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 0d4e1098a8787c1db9b3ac1d8f3f5b861735b837 +Subproject commit 2cfefb8f39d84412920d17be4ba76ebaabf2d6a6 From cd42c773044b6b270df1abd26bcc29db6856f1c9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 16 Dec 2023 16:33:54 -0700 Subject: [PATCH 127/127] playback: use ffmpeg first Always decode with ffmpeg before decoding with MediaCodec. MediaCodec is unreliable on some devices in such a way as to cause a full loading failure on them. Prevent this by using ffmpeg. --- CHANGELOG.md | 4 ++++ .../java/org/oxycblt/auxio/playback/system/PlaybackService.kt | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cac8eb4..0dcca4f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - Albums linked to an artist only as a collaborator are no longer included in an artist's album count +#### What's Fixed +- Fixed certain FLAC files failing to play on some devices + + ## 3.2.1 #### What's Improved 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 15c8cf0eb..7ac1c66cf 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 @@ -121,14 +121,14 @@ class PlaybackService : // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), MediaCodecAudioRenderer( this, MediaCodecSelector.DEFAULT, handler, audioListener, AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor), - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor)) + replayGainProcessor)) } player =