From 95057ec357cdf748babd3d710b3aecee489f9c74 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Wed, 23 Mar 2022 09:18:08 -0600 Subject: [PATCH] all: move all fragments to ViewBindingFragment Move all fragment instances to the new ViewBindingFragment paradigm, which should help immensely with reducing memory leaks from list bindings and to really alleviate the overloaded onCreate functions. --- .../main/java/org/oxycblt/auxio/AuxioApp.kt | 9 +- .../java/org/oxycblt/auxio/IntegerTable.kt | 3 +- .../java/org/oxycblt/auxio/MainFragment.kt | 132 ++++---- .../auxio/accent/AccentCustomizeDialog.kt | 56 ++-- .../org/oxycblt/auxio/coil/BaseFetcher.kt | 18 +- .../auxio/coil/SquareFrameTransform.kt | 8 +- .../auxio/detail/AlbumDetailFragment.kt | 154 ++++----- .../auxio/detail/ArtistDetailFragment.kt | 119 ++++--- .../oxycblt/auxio/detail/DetailFragment.kt | 13 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 69 ++-- .../auxio/detail/GenreDetailFragment.kt | 93 +++--- .../org/oxycblt/auxio/home/HomeFragment.kt | 314 +++++++++--------- .../org/oxycblt/auxio/home/HomeViewModel.kt | 10 +- .../auxio/home/list/AlbumListFragment.kt | 19 +- .../auxio/home/list/ArtistListFragment.kt | 19 +- .../auxio/home/list/GenreListFragment.kt | 19 +- .../auxio/home/list/HomeListFragment.kt | 24 +- .../auxio/home/list/SongListFragment.kt | 20 +- .../auxio/home/tabs/TabCustomizeDialog.kt | 108 +++--- .../auxio/music/excluded/ExcludedDialog.kt | 52 ++- .../auxio/playback/PlaybackBarFragment.kt | 26 +- .../auxio/playback/PlaybackPanelFragment.kt | 131 ++++---- .../auxio/playback/PlaybackViewModel.kt | 8 +- .../auxio/playback/queue/QueueFragment.kt | 41 +-- .../oxycblt/auxio/playback/state/LoopMode.kt | 9 + .../auxio/playback/system/PlaybackService.kt | 5 + .../oxycblt/auxio/search/SearchFragment.kt | 85 ++--- .../oxycblt/auxio/settings/AboutFragment.kt | 25 +- .../auxio/settings/SettingsFragment.kt | 21 +- .../auxio/settings/SettingsListFragment.kt | 2 +- .../auxio/settings/pref/IntListPrefDialog.kt | 13 +- .../org/oxycblt/auxio/ui/BottomSheetLayout.kt | 5 + .../java/org/oxycblt/auxio/ui/DisplayMode.kt | 9 + .../org/oxycblt/auxio/ui/LifecycleDialog.kt | 46 --- .../main/java/org/oxycblt/auxio/ui/Sort.kt | 14 +- .../auxio/ui/ViewBindingDialogFragment.kt | 73 ++++ .../oxycblt/auxio/ui/ViewBindingFragment.kt | 35 +- .../java/org/oxycblt/auxio/util/ViewUtil.kt | 4 - .../layout-land/fragment_playback_panel.xml | 17 +- .../fragment_playback_panel.xml | 18 +- .../fragment_playback_panel.xml | 2 +- .../fragment_playback_panel.xml | 30 +- .../res/layout/fragment_playback_panel.xml | 26 +- app/src/main/res/layout/view_seek_bar.xml | 2 +- app/src/main/res/values/dimens.xml | 2 +- app/src/main/res/values/styles_android.xml | 1 + 46 files changed, 875 insertions(+), 1034 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/ui/LifecycleDialog.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 2d0429d1c..621265614 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -28,14 +28,7 @@ import org.oxycblt.auxio.coil.GenreImageFetcher import org.oxycblt.auxio.coil.MusicKeyer import org.oxycblt.auxio.settings.SettingsManager -/** - * TODO: Plan for a general UI rework - * ``` - * - Refactor fragment class - * - Remove databinding and dedup layouts - * - Rework RecyclerView management and item dragging - * ``` - */ +/** TODO: Rework RecyclerView management and item dragging */ @Suppress("UNUSED") class AuxioApp : Application(), ImageLoaderFactory { override fun onCreate() { diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 7ea9406cf..182544606 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio +/** A table containing all unique integer codes that Auxio uses. */ object IntegerTable { /** SongViewHolder */ const val ITEM_TYPE_SONG = 0xA000 @@ -49,7 +50,7 @@ object IntegerTable { /** QueueSongViewHolder */ const val ITEM_TYPE_QUEUE_SONG = 0xA00D - /** "Music playback" Notification channel */ + /** "Music playback" Notification code */ const val NOTIFICATION_CODE = 0xA0A0 /** Intent request code */ const val REQUEST_CODE = 0xA0C0 diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index be7c6799a..30c628f72 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -22,18 +22,18 @@ import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.findNavController import com.google.android.material.snackbar.Snackbar import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.logW /** @@ -43,31 +43,25 @@ import org.oxycblt.auxio.util.logW * * TODO: Add a new view with a stack trace whenever the music loading process fails. */ -class MainFragment : Fragment() { +class MainFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() - private var callback: Callback? = null + private var callback: DynamicBackPressedCallback? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentMainBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) + override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { + + // --- UI SETUP --- // Build the permission launcher here as you can only do it in onCreateView/onCreate val permLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { musicModel.reloadMusic(requireContext()) } - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - requireActivity() .onBackPressedDispatcher - .addCallback(viewLifecycleOwner, Callback(binding).also { callback = it }) + .addCallback(viewLifecycleOwner, DynamicBackPressedCallback().also { callback = it }) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // Auxio's layout completely breaks down when it's window is resized too small, @@ -87,59 +81,15 @@ class MainFragment : Fragment() { // Initialize music loading. Do it here so that it shows on every fragment that this // one contains. + // TODO: Move this to a service [automatic rescanning] musicModel.loadMusic(requireContext()) // Handle the music loader response. musicModel.loaderResponse.observe(viewLifecycleOwner) { response -> - // Handle the loader response. - when (response) { - // Ok, start restoring playback now - is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext()) - - // Error, show the error to the user - is MusicStore.Response.Err -> { - logW("Received Error") - - val errorRes = - when (response.kind) { - MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music - MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms - MusicStore.ErrorKind.FAILED -> R.string.err_load_failed - } - - val snackbar = - Snackbar.make(binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE) - - when (response.kind) { - MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> { - snackbar.setAction(R.string.lbl_retry) { - musicModel.reloadMusic(requireContext()) - } - } - MusicStore.ErrorKind.NO_PERMS -> { - snackbar.setAction(R.string.lbl_grant) { - permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - } - - snackbar.show() - } - else -> {} - } + handleLoaderResponse(response, permLauncher) } - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (song != null) { - binding.bottomSheetLayout.show() - } else { - binding.bottomSheetLayout.hide() - } - } - - logD("Fragment Created") - - return binding.root + playbackModel.song.observe(viewLifecycleOwner, ::updateSong) } override fun onResume() { @@ -152,12 +102,66 @@ class MainFragment : Fragment() { callback?.isEnabled = false } + private fun handleLoaderResponse( + response: MusicStore.Response?, + permLauncher: ActivityResultLauncher + ) { + val binding = requireBinding() + + // Handle the loader response. + when (response) { + // Ok, start restoring playback now + is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext()) + + // Error, show the error to the user + is MusicStore.Response.Err -> { + logW("Received Error") + + val errorRes = + when (response.kind) { + MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music + MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms + MusicStore.ErrorKind.FAILED -> R.string.err_load_failed + } + + val snackbar = + Snackbar.make(binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE) + + when (response.kind) { + MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> { + snackbar.setAction(R.string.lbl_retry) { + musicModel.reloadMusic(requireContext()) + } + } + MusicStore.ErrorKind.NO_PERMS -> { + snackbar.setAction(R.string.lbl_grant) { + permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + } + + snackbar.show() + } + null -> {} + } + } + + private fun updateSong(song: Song?) { + val binding = requireBinding() + if (song != null) { + binding.bottomSheetLayout.show() + } else { + binding.bottomSheetLayout.hide() + } + } + /** * A back press callback that handles how to respond to backwards navigation in the detail * fragments and the playback panel. */ - inner class Callback(private val binding: FragmentMainBinding) : OnBackPressedCallback(false) { + inner class DynamicBackPressedCallback : OnBackPressedCallback(false) { override fun handleOnBackPressed() { + val binding = requireBinding() if (!binding.bottomSheetLayout.collapse()) { val navController = binding.exploreNavHost.findNavController() diff --git a/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt index ed15a5bb9..2f8c95e16 100644 --- a/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/accent/AccentCustomizeDialog.kt @@ -19,54 +19,23 @@ package org.oxycblt.auxio.accent import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.settings.SettingsManager -import org.oxycblt.auxio.ui.LifecycleDialog +import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD /** * Dialog responsible for showing the list of accents to select. * @author OxygenCobalt */ -class AccentCustomizeDialog : LifecycleDialog() { +class AccentCustomizeDialog : ViewBindingDialogFragment() { private val settingsManager = SettingsManager.getInstance() private var pendingAccent = settingsManager.accent - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = DialogAccentBinding.inflate(inflater) - - savedInstanceState?.getInt(KEY_PENDING_ACCENT)?.let { index -> - pendingAccent = Accent(index) - } - - // --- UI SETUP --- - - binding.accentRecycler.apply { - adapter = - AccentAdapter(pendingAccent) { accent -> - logD("Switching selected accent to $accent") - pendingAccent = accent - } - } - - logD("Dialog created") - - return binding.root - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index) - } + override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.set_accent) @@ -85,6 +54,25 @@ class AccentCustomizeDialog : LifecycleDialog() { builder.setNegativeButton(android.R.string.cancel, null) } + override fun onBindingCreated(binding: DialogAccentBinding, savedInstanceState: Bundle?) { + savedInstanceState?.getInt(KEY_PENDING_ACCENT)?.let { index -> + pendingAccent = Accent(index) + } + + // --- UI SETUP --- + + binding.accentRecycler.adapter = + AccentAdapter(pendingAccent) { accent -> + logD("Switching selected accent to $accent") + pendingAccent = accent + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index) + } + companion object { const val TAG = BuildConfig.APPLICATION_ID + ".tag.ACCENT_PICKER" const val KEY_PENDING_ACCENT = BuildConfig.APPLICATION_ID + ".key.PENDING_ACCENT" diff --git a/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt index 1aa9ddc8b..fd008365b 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/BaseFetcher.kt @@ -58,7 +58,7 @@ abstract class BaseFetcher : Fetcher { /** * Fetch the artwork of an [album]. This call respects user configuration and has proper - * redundancy in the case that an API fails to load. + * redundancy in the case that metadata fails to load. */ protected suspend fun fetchArt(context: Context, album: Album): InputStream? { if (!settingsManager.showCovers) { @@ -77,14 +77,6 @@ abstract class BaseFetcher : Fetcher { } } - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? { - val uri = data.albumCoverUri - - // Eliminate any chance that this blocking call might mess up the cancellation process - return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } - } - private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? { // Loading quality covers basically means to parse the file metadata ourselves // and then extract the cover. @@ -207,6 +199,14 @@ abstract class BaseFetcher : Fetcher { return stream } + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? { + val uri = data.albumCoverUri + + // Eliminate any chance that this blocking call might mess up the loading process + return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } + } + /** * Create a mosaic image from multiple streams of image data, Code adapted from Phonograph * https://github.com/kabouzeid/Phonograph diff --git a/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt index c7294d1a6..58606717c 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/SquareFrameTransform.kt @@ -39,14 +39,14 @@ class SquareFrameTransform : Transformation { val x = (input.width - dstSize) / 2 val y = (input.height - dstSize) / 2 - val wantedWidth = size.width.pxOrElse { dstSize } - val wantedHeight = size.height.pxOrElse { dstSize } + val desiredWidth = size.width.pxOrElse { dstSize } + val desiredHeight = size.height.pxOrElse { dstSize } val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) - if (dstSize != wantedWidth || dstSize != wantedHeight) { + if (dstSize != desiredWidth || dstSize != desiredHeight) { // Desired size differs from the cropped size, resize the bitmap. - return Bitmap.createScaledBitmap(dst, wantedWidth, wantedHeight, true) + return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) } return dst 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 c08de53c2..3c90a7a32 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -19,9 +19,6 @@ package org.oxycblt.auxio.detail import android.content.Context import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.core.view.children import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -33,6 +30,7 @@ import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Header +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.ui.ActionMenu @@ -49,14 +47,9 @@ import org.oxycblt.auxio.util.showToast class AlbumDetailFragment : DetailFragment() { private val args: AlbumDetailFragmentArgs by navArgs() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - detailModel.setAlbum(args.albumId) + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + detailModel.setAlbumId(args.albumId) - val binding = FragmentDetailBinding.inflate(layoutInflater) val detailAdapter = AlbumDetailAdapter( playbackModel, @@ -64,11 +57,7 @@ class AlbumDetailFragment : DetailFragment() { doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_ALBUM) }, doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ALBUM) }) - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - setupToolbar(detailModel.curAlbum.value!!, binding, R.menu.menu_album_detail) { itemId -> + setupToolbar(detailModel.curAlbum.value!!, R.menu.menu_album_detail) { itemId -> when (itemId) { R.id.action_play_next -> { playbackModel.playNext(detailModel.curAlbum.value!!) @@ -84,16 +73,14 @@ class AlbumDetailFragment : DetailFragment() { } } - setupRecycler(binding, detailAdapter) { pos -> + setupRecycler(detailAdapter) { pos -> val item = detailAdapter.currentList[pos] item is Header || item is ActionHeader || item is Album } - updateQueueActions(playbackModel.song.value, binding) + // -- VIEWMODEL SETUP --- - // -- DETAILVIEWMODEL SETUP --- - - detailModel.albumData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) } + detailModel.albumData.observe(viewLifecycleOwner, detailAdapter::submitList) detailModel.showMenu.observe(viewLifecycleOwner) { config -> if (config != null) { @@ -102,84 +89,62 @@ class AlbumDetailFragment : DetailFragment() { } detailModel.navToItem.observe(viewLifecycleOwner) { item -> - when (item) { - // Songs should be scrolled to if the album matches, or a new detail - // fragment should be launched otherwise. - is Song -> { - if (detailModel.curAlbum.value!!.id == item.album.id) { - logD("Navigating to a song in this album") - scrollToItem(item.id, binding, detailAdapter) - detailModel.finishNavToItem() - } else { - logD("Navigating to another album") - findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)) - } - } - - // If the album matches, no need to do anything. Otherwise launch a new - // detail fragment. - is Album -> { - if (detailModel.curAlbum.value!!.id == item.id) { - logD("Navigating to the top of this album") - binding.detailRecycler.scrollToPosition(0) - detailModel.finishNavToItem() - } else { - logD("Navigating to another album") - findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id)) - } - } - - // Always launch a new ArtistDetailFragment. - is Artist -> { - logD("Navigating to another artist") - findNavController() - .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id)) - } - null -> {} - else -> logW("Unsupported navigation item ${item::class.java}") - } + handleNavigation(item, detailAdapter) } - // --- PLAYBACKVIEWMODEL SETUP --- - - playbackModel.song.observe(viewLifecycleOwner) { song -> - updateQueueActions(song, binding) - - if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM && - playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) { - detailAdapter.highlightSong(song, binding.detailRecycler) - } else { - // Clear the ViewHolders if the mode isn't ALL_SONGS - detailAdapter.highlightSong(null, binding.detailRecycler) - } - } - - logD("Fragment created") - - return binding.root + updateSong(playbackModel.song.value, detailAdapter) + playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } } - /** Updates the queue actions when */ - private fun updateQueueActions(song: Song?, binding: FragmentDetailBinding) { - for (item in binding.detailToolbar.menu.children) { - if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) { - item.isEnabled = song != null + private fun handleNavigation(item: Music?, adapter: AlbumDetailAdapter) { + val binding = requireBinding() + when (item) { + // Songs should be scrolled to if the album matches, or a new detail + // fragment should be launched otherwise. + is Song -> { + if (detailModel.curAlbum.value!!.id == item.album.id) { + logD("Navigating to a song in this album") + scrollToItem(item.id, adapter) + detailModel.finishNavToItem() + } else { + logD("Navigating to another album") + findNavController() + .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id)) + } } + + // If the album matches, no need to do anything. Otherwise launch a new + // detail fragment. + is Album -> { + if (detailModel.curAlbum.value!!.id == item.id) { + logD("Navigating to the top of this album") + binding.detailRecycler.scrollToPosition(0) + detailModel.finishNavToItem() + } else { + logD("Navigating to another album") + findNavController() + .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id)) + } + } + + // Always launch a new ArtistDetailFragment. + is Artist -> { + logD("Navigating to another artist") + findNavController() + .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id)) + } + null -> {} + else -> logW("Unsupported navigation item ${item::class.java}") } } /** Scroll to an song using its [id]. */ - private fun scrollToItem( - id: Long, - binding: FragmentDetailBinding, - adapter: AlbumDetailAdapter - ) { + private fun scrollToItem(id: Long, adapter: AlbumDetailAdapter) { // Calculate where the item for the currently played song is val pos = adapter.currentList.indexOfFirst { it.id == id && it is Song } if (pos != -1) { + val binding = requireBinding() binding.detailRecycler.post { // Make sure to increment the position to make up for the detail header binding.detailRecycler.layoutManager?.startSmoothScroll( @@ -193,6 +158,25 @@ class AlbumDetailFragment : DetailFragment() { } } + /** Updates the queue actions when a song is present or not */ + private fun updateSong(song: Song?, adapter: AlbumDetailAdapter) { + val binding = requireBinding() + + for (item in binding.detailToolbar.menu.children) { + if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) { + item.isEnabled = song != null + } + } + + if (playbackModel.playbackMode.value == PlaybackMode.IN_ALBUM && + playbackModel.parent.value?.id == detailModel.curAlbum.value!!.id) { + adapter.highlightSong(song, binding.detailRecycler) + } else { + // Clear the ViewHolders if the mode isn't ALL_SONGS + adapter.highlightSong(null, binding.detailRecycler) + } + } + /** * [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to * the top or bottom. 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 778cefeef..3e1d53629 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -18,9 +18,6 @@ package org.oxycblt.auxio.detail import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.R @@ -30,6 +27,8 @@ import org.oxycblt.auxio.music.ActionHeader import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Header +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.ui.ActionMenu @@ -44,21 +43,15 @@ import org.oxycblt.auxio.util.logW class ArtistDetailFragment : DetailFragment() { private val args: ArtistDetailFragmentArgs by navArgs() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - detailModel.setArtist(args.artistId) + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + detailModel.setArtistId(args.artistId) - val binding = FragmentDetailBinding.inflate(layoutInflater) val detailAdapter = ArtistDetailAdapter( playbackModel, doOnClick = { data -> if (!detailModel.isNavigating) { detailModel.setNavigating(true) - findNavController() .navigate(ArtistDetailFragmentDirections.actionShowAlbum(data.id)) } @@ -66,12 +59,8 @@ class ArtistDetailFragment : DetailFragment() { doOnSongClick = { data -> playbackModel.playSong(data, PlaybackMode.IN_ARTIST) }, doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_ARTIST) }) - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - setupToolbar(detailModel.curArtist.value!!, binding) - setupRecycler(binding, detailAdapter) { pos -> + setupToolbar(detailModel.currentArtist.value!!) + setupRecycler(detailAdapter) { pos -> // If the item is an ActionHeader we need to also make the item full-width val item = detailAdapter.currentList[pos] item is Header || item is ActionHeader || item is Artist @@ -79,9 +68,7 @@ class ArtistDetailFragment : DetailFragment() { // --- VIEWMODEL SETUP --- - detailModel.artistData.observe(viewLifecycleOwner) { data -> - detailAdapter.submitList(data) - } + detailModel.artistData.observe(viewLifecycleOwner, detailAdapter::submitList) detailModel.showMenu.observe(viewLifecycleOwner) { config -> if (config != null) { @@ -89,56 +76,64 @@ class ArtistDetailFragment : DetailFragment() { } } - detailModel.navToItem.observe(viewLifecycleOwner) { item -> - when (item) { - is Artist -> { - if (item.id == detailModel.curArtist.value?.id) { - logD("Navigating to the top of this artist") - binding.detailRecycler.scrollToPosition(0) - detailModel.finishNavToItem() - } else { - logD("Navigating to another artist") - findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id)) - } - } - is Album -> { - logD("Navigating to another album") - findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) - } - is Song -> { - logD("Navigating to another album") - findNavController() - .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)) - } - null -> {} - else -> logW("Unsupported navigation item ${item::class.java}") - } - } + detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) + + // Highlight songs if they are being played + playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } // Highlight albums if they are being played playbackModel.parent.observe(viewLifecycleOwner) { parent -> - if (parent is Album?) { - detailAdapter.highlightAlbum(parent, binding.detailRecycler) - } else { - detailAdapter.highlightAlbum(null, binding.detailRecycler) - } + updateParent(parent, detailAdapter) } + } - // Highlight songs if they are being played - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST && - playbackModel.parent.value?.id == detailModel.curArtist.value?.id) { - detailAdapter.highlightSong(song, binding.detailRecycler) - } else { - // Clear the ViewHolders if the mode isn't ALL_SONGS - detailAdapter.highlightSong(null, binding.detailRecycler) + private fun handleNavigation(item: Music?) { + val binding = requireBinding() + + when (item) { + is Artist -> { + if (item.id == detailModel.currentArtist.value?.id) { + logD("Navigating to the top of this artist") + binding.detailRecycler.scrollToPosition(0) + detailModel.finishNavToItem() + } else { + logD("Navigating to another artist") + findNavController() + .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id)) + } } + is Album -> { + logD("Navigating to another album") + findNavController() + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) + } + is Song -> { + logD("Navigating to another album") + findNavController() + .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id)) + } + null -> {} + else -> logW("Unsupported navigation item ${item::class.java}") } + } - logD("Fragment created") + private fun updateSong(song: Song?, adapter: ArtistDetailAdapter) { + val binding = requireBinding() + if (playbackModel.playbackMode.value == PlaybackMode.IN_ARTIST && + playbackModel.parent.value?.id == detailModel.currentArtist.value?.id) { + adapter.highlightSong(song, binding.detailRecycler) + } else { + // Clear the ViewHolders if the mode isn't ALL_SONGS + adapter.highlightSong(null, binding.detailRecycler) + } + } - return binding.root + private fun updateParent(parent: MusicParent?, adapter: ArtistDetailAdapter) { + val binding = requireBinding() + if (parent is Album?) { + adapter.highlightAlbum(parent, binding.detailRecycler) + } else { + adapter.highlightAlbum(null, binding.detailRecycler) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt index bc66bca2a..4b199651b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt @@ -17,6 +17,7 @@ package org.oxycblt.auxio.detail +import android.view.LayoutInflater import androidx.annotation.MenuRes import androidx.appcompat.widget.PopupMenu import androidx.core.view.children @@ -28,6 +29,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.logD @@ -35,10 +37,13 @@ import org.oxycblt.auxio.util.logD * A Base [Fragment] implementing the base features shared across all detail fragments. * @author OxygenCobalt */ -abstract class DetailFragment : Fragment() { +abstract class DetailFragment : ViewBindingFragment() { protected val detailModel: DetailViewModel by activityViewModels() protected val playbackModel: PlaybackViewModel by activityViewModels() + override fun onCreateBinding(inflater: LayoutInflater): FragmentDetailBinding = + FragmentDetailBinding.inflate(inflater) + override fun onResume() { super.onResume() detailModel.setNavigating(false) @@ -58,11 +63,10 @@ abstract class DetailFragment : Fragment() { */ protected fun setupToolbar( data: MusicParent, - binding: FragmentDetailBinding, @MenuRes menuId: Int = -1, onMenuClick: ((itemId: Int) -> Boolean)? = null ) { - binding.detailToolbar.apply { + requireBinding().detailToolbar.apply { title = data.resolvedName if (menuId != -1) { @@ -79,11 +83,10 @@ abstract class DetailFragment : Fragment() { /** Shortcut method for recyclerview setup */ protected fun setupRecycler( - binding: FragmentDetailBinding, detailAdapter: RecyclerView.Adapter, gridLookup: (Int) -> Boolean ) { - binding.detailRecycler.apply { + requireBinding().detailRecycler.apply { adapter = detailAdapter setHasFixedSize(true) applySpans(gridLookup) 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 33d743414..fc8281d3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -28,6 +28,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Header import org.oxycblt.auxio.music.Item +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.DisplayMode @@ -43,39 +44,37 @@ import org.oxycblt.auxio.util.logD * @author OxygenCobalt */ class DetailViewModel : ViewModel() { - // --- CURRENT VALUES --- - - private val mCurGenre = MutableLiveData() - val curGenre: LiveData - get() = mCurGenre - - private val mGenreData = MutableLiveData(listOf()) - val genreData: LiveData> = mGenreData - - private val mCurArtist = MutableLiveData() - val curArtist: LiveData - get() = mCurArtist - - private val mArtistData = MutableLiveData(listOf()) - val artistData: LiveData> = mArtistData - - private val mCurAlbum = MutableLiveData() + private val mCurrentAlbum = MutableLiveData() val curAlbum: LiveData - get() = mCurAlbum + get() = mCurrentAlbum private val mAlbumData = MutableLiveData(listOf()) val albumData: LiveData> get() = mAlbumData + private val mCurrentArtist = MutableLiveData() + val currentArtist: LiveData + get() = mCurrentArtist + + private val mArtistData = MutableLiveData(listOf()) + val artistData: LiveData> = mArtistData + + private val mCurrentGenre = MutableLiveData() + val currentGenre: LiveData + get() = mCurrentGenre + + private val mGenreData = MutableLiveData(listOf()) + val genreData: LiveData> = mGenreData + data class MenuConfig(val anchor: View, val sortMode: Sort) private val mShowMenu = MutableLiveData(null) val showMenu: LiveData = mShowMenu - private val mNavToItem = MutableLiveData() + private val mNavToItem = MutableLiveData() /** Flag for unified navigation. Observe this to coordinate navigation to an item's UI. */ - val navToItem: LiveData + val navToItem: LiveData get() = mNavToItem var isNavigating = false @@ -84,25 +83,25 @@ class DetailViewModel : ViewModel() { private var currentMenuContext: DisplayMode? = null private val settingsManager = SettingsManager.getInstance() - fun setGenre(id: Long) { - if (mCurGenre.value?.id == id) return + fun setAlbumId(id: Long) { + if (mCurrentAlbum.value?.id == id) return val musicStore = MusicStore.requireInstance() - mCurGenre.value = musicStore.genres.find { it.id == id } - refreshGenreData() + mCurrentAlbum.value = musicStore.albums.find { it.id == id } + refreshAlbumData() } - fun setArtist(id: Long) { - if (mCurArtist.value?.id == id) return + fun setArtistId(id: Long) { + if (mCurrentArtist.value?.id == id) return val musicStore = MusicStore.requireInstance() - mCurArtist.value = musicStore.artists.find { it.id == id } + mCurrentArtist.value = musicStore.artists.find { it.id == id } refreshArtistData() } - fun setAlbum(id: Long) { - if (mCurAlbum.value?.id == id) return + fun setGenreId(id: Long) { + if (mCurrentGenre.value?.id == id) return val musicStore = MusicStore.requireInstance() - mCurAlbum.value = musicStore.albums.find { it.id == id } - refreshAlbumData() + mCurrentGenre.value = musicStore.genres.find { it.id == id } + refreshGenreData() } /** Mark that the menu process is done with the new [Sort]. Pass null if there was no change. */ @@ -132,7 +131,7 @@ class DetailViewModel : ViewModel() { } /** Navigate to an item, whether a song/album/artist */ - fun navToItem(item: Item) { + fun navToItem(item: Music) { mNavToItem.value = item } @@ -148,7 +147,7 @@ class DetailViewModel : ViewModel() { private fun refreshGenreData() { logD("Refreshing genre data") - val genre = requireNotNull(curGenre.value) + val genre = requireNotNull(currentGenre.value) val data = mutableListOf(genre) data.add( @@ -162,14 +161,14 @@ class DetailViewModel : ViewModel() { mShowMenu.value = MenuConfig(view, settingsManager.detailGenreSort) })) - data.addAll(settingsManager.detailGenreSort.genre(curGenre.value!!)) + data.addAll(settingsManager.detailGenreSort.genre(currentGenre.value!!)) mGenreData.value = data } private fun refreshArtistData() { logD("Refreshing artist data") - val artist = requireNotNull(curArtist.value) + val artist = requireNotNull(currentArtist.value) val data = mutableListOf(artist) data.add(Header(id = -2, string = R.string.lbl_albums)) 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 583341066..12b755de8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -18,9 +18,6 @@ package org.oxycblt.auxio.detail import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import org.oxycblt.auxio.databinding.FragmentDetailBinding @@ -30,6 +27,7 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Header +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.ui.ActionMenu @@ -44,77 +42,66 @@ import org.oxycblt.auxio.util.logW class GenreDetailFragment : DetailFragment() { private val args: GenreDetailFragmentArgs by navArgs() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - detailModel.setGenre(args.genreId) + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + detailModel.setGenreId(args.genreId) - val binding = FragmentDetailBinding.inflate(inflater) val detailAdapter = GenreDetailAdapter( playbackModel, doOnClick = { song -> playbackModel.playSong(song, PlaybackMode.IN_GENRE) }, doOnLongClick = { view, data -> newMenu(view, data, ActionMenu.FLAG_IN_GENRE) }) - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - setupToolbar(detailModel.curGenre.value!!, binding) - setupRecycler(binding, detailAdapter) { pos -> + setupToolbar(detailModel.currentGenre.value!!) + setupRecycler(detailAdapter) { pos -> val item = detailAdapter.currentList[pos] item is Header || item is ActionHeader || item is Genre } - // --- DETAILVIEWMODEL SETUP --- + // --- VIEWMODEL SETUP --- - detailModel.genreData.observe(viewLifecycleOwner) { data -> detailAdapter.submitList(data) } + detailModel.genreData.observe(viewLifecycleOwner, detailAdapter::submitList) - detailModel.navToItem.observe(viewLifecycleOwner) { item -> - when (item) { - // All items will launch new detail fragments. - is Artist -> { - logD("Navigating to another artist") - findNavController() - .navigate(GenreDetailFragmentDirections.actionShowArtist(item.id)) - } - is Album -> { - logD("Navigating to another album") - findNavController() - .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id)) - } - is Song -> { - logD("Navigating to another song") - findNavController() - .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id)) - } - null -> {} - else -> logW("Unsupported navigation command ${item::class.java}") - } - } + detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) - // --- PLAYBACKVIEWMODEL SETUP --- - - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE && - playbackModel.parent.value?.id == detailModel.curGenre.value!!.id) { - detailAdapter.highlightSong(song, binding.detailRecycler) - } else { - // Clear the ViewHolders if the mode isn't ALL_SONGS - detailAdapter.highlightSong(null, binding.detailRecycler) - } - } + playbackModel.song.observe(viewLifecycleOwner) { song -> updateSong(song, detailAdapter) } detailModel.showMenu.observe(viewLifecycleOwner) { config -> if (config != null) { showMenu(config) } } + } - logD("Fragment created") + private fun handleNavigation(item: Music?) { + when (item) { + // All items will launch new detail fragments. + is Artist -> { + logD("Navigating to another artist") + findNavController() + .navigate(GenreDetailFragmentDirections.actionShowArtist(item.id)) + } + is Album -> { + logD("Navigating to another album") + findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id)) + } + is Song -> { + logD("Navigating to another song") + findNavController() + .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id)) + } + null -> {} + else -> logW("Unsupported navigation command ${item::class.java}") + } + } - return binding.root + private fun updateSong(song: Song?, adapter: GenreDetailAdapter) { + val binding = requireBinding() + if (playbackModel.playbackMode.value == PlaybackMode.IN_GENRE && + playbackModel.parent.value?.id == detailModel.currentGenre.value!!.id) { + adapter.highlightSong(song, binding.detailRecycler) + } else { + // Clear the ViewHolders if the mode isn't ALL_SONGS + adapter.highlightSong(null, binding.detailRecycler) + } } } 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 3edbeb4d4..b0782e67f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -20,8 +20,6 @@ package org.oxycblt.auxio.home import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem -import android.view.View -import android.view.ViewGroup import androidx.core.view.iterator import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -41,11 +39,13 @@ import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logTraceOrThrow @@ -59,104 +59,35 @@ import org.oxycblt.auxio.util.logTraceOrThrow * * TODO: Add duration and song count sorts */ -class HomeFragment : Fragment() { +class HomeFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentHomeBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater) + + override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { val sortItem: MenuItem - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - binding.homeToolbar.apply { + sortItem = menu.findItem(R.id.submenu_sorting) + setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.action_search -> { - logD("Navigating to search") - findNavController().navigate(HomeFragmentDirections.actionShowSearch()) - } - R.id.action_settings -> { - logD("Navigating to settings") - parentFragment - ?.parentFragment - ?.findNavController() - ?.navigate(MainFragmentDirections.actionShowSettings()) - } - R.id.action_about -> { - logD("Navigating to about") - parentFragment - ?.parentFragment - ?.findNavController() - ?.navigate(MainFragmentDirections.actionShowAbout()) - } - R.id.submenu_sorting -> {} - R.id.option_sort_asc -> { - item.isChecked = !item.isChecked - val new = - homeModel - .getSortForDisplay(homeModel.curTab.value!!) - .ascending(item.isChecked) - homeModel.updateCurrentSort(new) - } - - // Sorting option was selected, mark it as selected and update the mode - else -> { - item.isChecked = true - val new = - homeModel - .getSortForDisplay(homeModel.curTab.value!!) - .assignId(item.itemId) - homeModel.updateCurrentSort(requireNotNull(new)) - } - } - + onMenuClick(item) true } - - sortItem = menu.findItem(R.id.submenu_sorting) } binding.homePager.apply { adapter = HomePagerAdapter() - - // By default, ViewPager2's sensitivity is high enough to result in vertical - // scroll events being registered as horizontal scroll events. Reflect into the - // internal recyclerview and change the touch slope so that touch actions will - // act more as a scroll than as a swipe. - // Derived from: - // https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414 - try { - val recycler = - ViewPager2::class.java.getDeclaredField("mRecyclerView").run { - isAccessible = true - get(binding.homePager) - } - - RecyclerView::class.java.getDeclaredField("mTouchSlop").apply { - isAccessible = true - - val slop = get(recycler) as Int - set(recycler, slop * 3) // 3x seems to be the best fit here - } - } catch (e: Exception) { - logE("Unable to reduce ViewPager sensitivity (likely an internal code change)") - e.logTraceOrThrow() - } - // We know that there will only be a fixed amount of tabs, so we manually set this // limit to that. This also prevents the appbar lift state from being confused during // page transitions. offscreenPageLimit = homeModel.tabs.size + reduceSensitivity(3) + registerOnPageChangeCallback( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) = @@ -171,89 +102,94 @@ class HomeFragment : Fragment() { // --- VIEWMODEL SETUP --- - // There is no way a fast scrolling event can continue across a re-create. Reset it. - homeModel.updateFastScrolling(false) + homeModel.fastScrolling.observe(viewLifecycleOwner, ::updateFastScrolling) + homeModel.currentTab.observe(viewLifecycleOwner) { tab -> updateCurrentTab(sortItem, tab) } + homeModel.recreateTabs.observe(viewLifecycleOwner, ::handleRecreateTabs) - musicModel.loaderResponse.observe(viewLifecycleOwner) { response -> - // Handle the loader response. - when (response) { - is MusicStore.Response.Ok -> binding.homeFab.show() + musicModel.loaderResponse.observe(viewLifecycleOwner, ::handleLoaderResponse) + detailModel.navToItem.observe(viewLifecycleOwner, ::handleNavigation) + } - // While loading or during an error, make sure we keep the shuffle fab hidden so - // that any kind of playback is impossible. PlaybackStateManager also relies on this - // invariant, so please don't change it. - else -> binding.homeFab.hide() + private fun onMenuClick(item: MenuItem) { + when (item.itemId) { + R.id.action_search -> { + logD("Navigating to search") + findNavController().navigate(HomeFragmentDirections.actionShowSearch()) + } + R.id.action_settings -> { + logD("Navigating to settings") + parentFragment + ?.parentFragment + ?.findNavController() + ?.navigate(MainFragmentDirections.actionShowSettings()) + } + R.id.action_about -> { + logD("Navigating to about") + parentFragment + ?.parentFragment + ?.findNavController() + ?.navigate(MainFragmentDirections.actionShowAbout()) + } + R.id.submenu_sorting -> {} + R.id.option_sort_asc -> { + item.isChecked = !item.isChecked + homeModel.updateCurrentSort( + requireNotNull( + homeModel + .getSortForDisplay(homeModel.currentTab.value!!) + .ascending(item.isChecked))) + } + + // Sorting option was selected, mark it as selected and update the mode + else -> { + item.isChecked = true + homeModel.updateCurrentSort( + requireNotNull( + homeModel + .getSortForDisplay(homeModel.currentTab.value!!) + .assignId(item.itemId))) } } + } - homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling -> - // Make sure an update here doesn't mess up the FAB state when it comes to the - // loader response. - if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) { - return@observe - } + private fun updateFastScrolling(isFastScrolling: Boolean) { + val binding = requireBinding() - if (scrolling) { - binding.homeFab.hide() - } else { - binding.homeFab.show() - } + // Make sure an update here doesn't mess up the FAB state when it comes to the + // loader response. + if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) { + return } - homeModel.recreateTabs.observe(viewLifecycleOwner) { recreate -> - // notifyDataSetChanged is not practical for recreating here since it will cache - // the previous fragments. Just instantiate a whole new adapter. - if (recreate) { - binding.homePager.currentItem = 0 - binding.homePager.adapter = HomePagerAdapter() - homeModel.finishRecreateTabs() + if (isFastScrolling) { + binding.homeFab.hide() + } else { + binding.homeFab.show() + } + } + + private fun updateCurrentTab(sortItem: MenuItem, tab: DisplayMode) { + // Make sure that we update the scrolling view and allowed menu items whenever + // the tab changes. + val binding = requireBinding() + when (tab) { + DisplayMode.SHOW_SONGS -> { + updateSortMenu(sortItem, tab) + binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list + } + DisplayMode.SHOW_ALBUMS -> { + updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album } + binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list + } + DisplayMode.SHOW_ARTISTS -> { + updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } + binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list + } + DisplayMode.SHOW_GENRES -> { + updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } + binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list } } - - homeModel.curTab.observe(viewLifecycleOwner) { t -> - val tab = requireNotNull(t) - - // Make sure that we update the scrolling view and allowed menu items whenever - // the tab changes. - when (tab) { - DisplayMode.SHOW_SONGS -> updateSortMenu(sortItem, tab) - DisplayMode.SHOW_ALBUMS -> - updateSortMenu(sortItem, tab) { id -> id != R.id.option_sort_album } - DisplayMode.SHOW_ARTISTS -> - updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } - DisplayMode.SHOW_GENRES -> - updateSortMenu(sortItem, tab) { id -> id == R.id.option_sort_asc } - } - - binding.homeAppbar.liftOnScrollTargetViewId = tab.viewId - } - - detailModel.navToItem.observe(viewLifecycleOwner) { item -> - // The AppBarLayout gets confused when we navigate too fast, wait for it to draw - // before we navigate. - // This is only here just in case a collapsing toolbar is re-added. - binding.homeAppbar.post { - when (item) { - is Song -> - findNavController() - .navigate(HomeFragmentDirections.actionShowAlbum(item.album.id)) - is Album -> - findNavController() - .navigate(HomeFragmentDirections.actionShowAlbum(item.id)) - is Artist -> - findNavController() - .navigate(HomeFragmentDirections.actionShowArtist(item.id)) - is Genre -> - findNavController() - .navigate(HomeFragmentDirections.actionShowGenre(item.id)) - else -> {} - } - } - } - - logD("Fragment Created") - - return binding.root } private fun updateSortMenu( @@ -276,14 +212,72 @@ class HomeFragment : Fragment() { } } - private val DisplayMode.viewId: Int - get() = - when (this) { - DisplayMode.SHOW_SONGS -> R.id.home_song_list - DisplayMode.SHOW_ALBUMS -> R.id.home_album_list - DisplayMode.SHOW_ARTISTS -> R.id.home_artist_list - DisplayMode.SHOW_GENRES -> R.id.home_genre_list + private fun handleRecreateTabs(recreate: Boolean) { + if (recreate) { + requireBinding().homePager.recreate() + homeModel.finishRecreateTabs() + } + } + + private fun handleLoaderResponse(response: MusicStore.Response?) { + val binding = requireBinding() + when (response) { + is MusicStore.Response.Ok -> binding.homeFab.show() + + // While loading or during an error, make sure we keep the shuffle fab hidden so + // that any kind of playback is impossible. PlaybackStateManager also relies on this + // invariant, so please don't change it. + else -> binding.homeFab.hide() + } + } + + private fun handleNavigation(item: Music?) { + // Note: You will want to add a post call to this if you want to re-introduce a collapsing + // toolbar. + when (item) { + is Song -> + findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.album.id)) + is Album -> + findNavController().navigate(HomeFragmentDirections.actionShowAlbum(item.id)) + is Artist -> + findNavController().navigate(HomeFragmentDirections.actionShowArtist(item.id)) + is Genre -> + findNavController().navigate(HomeFragmentDirections.actionShowGenre(item.id)) + else -> {} + } + } + + /** + * By default, ViewPager2's sensitivity is high enough to result in vertical scroll events being + * registered as horizontal scroll events. Reflect into the internal recyclerview and change the + * touch slope so that touch actions will act more as a scroll than as a swipe. Derived from: + * https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414 + */ + private fun ViewPager2.reduceSensitivity(by: Int) { + try { + val recycler = + ViewPager2::class.java.getDeclaredField("mRecyclerView").run { + isAccessible = true + get(this@reduceSensitivity) + } + + RecyclerView::class.java.getDeclaredField("mTouchSlop").apply { + isAccessible = true + + val slop = get(recycler) as Int + set(recycler, slop * by) } + } catch (e: Exception) { + logE("Unable to reduce ViewPager sensitivity (likely an internal code change)") + e.logTraceOrThrow() + } + } + + /** Forces the view to recreate all fragments contained within it. */ + private fun ViewPager2.recreate() { + currentItem = 0 + adapter = HomePagerAdapter() + } private inner class HomePagerAdapter : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index ee1073b78..edeeccfb4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -63,8 +63,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback { private val visibleTabs: List get() = settingsManager.libTabs.filterIsInstance().map { it.mode } - private val mCurTab = MutableLiveData(tabs[0]) - val curTab: LiveData = mCurTab + private val mCurrentTab = MutableLiveData(tabs[0]) + val currentTab: LiveData = mCurrentTab /** * Marker to recreate all library tabs, usually initiated by a settings change. When this flag @@ -91,7 +91,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback { /** Update the current tab based off of the new ViewPager position. */ fun updateCurrentTab(pos: Int) { logD("Updating current tab to ${tabs[pos]}") - mCurTab.value = tabs[pos] + mCurrentTab.value = tabs[pos] } fun finishRecreateTabs() { @@ -109,8 +109,8 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback { /** Update the currently displayed item's [Sort]. */ fun updateCurrentSort(sort: Sort) { - logD("Updating ${mCurTab.value} sort to $sort") - when (mCurTab.value) { + logD("Updating ${mCurrentTab.value} sort to $sort") + when (mCurrentTab.value) { DisplayMode.SHOW_SONGS -> { settingsManager.libSongSort = sort mSongs.value = sort.songs(mSongs.value!!) 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 830b310df..c779d29af 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 @@ -18,7 +18,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController @@ -37,27 +36,15 @@ import org.oxycblt.auxio.ui.sliceArticle * @author */ class AlbumListFragment : HomeListFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentHomeListBinding.inflate(layoutInflater) - - // / --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - val adapter = + override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { + val homeAdapter = AlbumAdapter( doOnClick = { album -> findNavController().navigate(HomeFragmentDirections.actionShowAlbum(album.id)) }, ::newMenu) - setupRecycler(R.id.home_album_list, binding, adapter, homeModel.albums) - - return binding.root + setupRecycler(R.id.home_album_list, homeAdapter, homeModel.albums) } override val listPopupProvider: (Int) -> String 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 9a3b6620f..999f1193d 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 @@ -18,7 +18,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController @@ -35,27 +34,15 @@ import org.oxycblt.auxio.ui.sliceArticle * @author */ class ArtistListFragment : HomeListFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentHomeListBinding.inflate(layoutInflater) - - // / --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - val adapter = + override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { + val homeAdapter = ArtistAdapter( doOnClick = { artist -> findNavController().navigate(HomeFragmentDirections.actionShowArtist(artist.id)) }, ::newMenu) - setupRecycler(R.id.home_artist_list, binding, adapter, homeModel.artists) - - return binding.root + setupRecycler(R.id.home_artist_list, homeAdapter, homeModel.artists) } override val listPopupProvider: (Int) -> String 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 70be379ce..d058eb5be 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 @@ -18,7 +18,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.navigation.fragment.findNavController @@ -35,27 +34,15 @@ import org.oxycblt.auxio.ui.sliceArticle * @author */ class GenreListFragment : HomeListFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentHomeListBinding.inflate(layoutInflater) - - // / --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - val adapter = + override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { + val homeAdapter = GenreAdapter( doOnClick = { Genre -> findNavController().navigate(HomeFragmentDirections.actionShowGenre(Genre.id)) }, ::newMenu) - setupRecycler(R.id.home_genre_list, binding, adapter, homeModel.genres) - - return binding.root + setupRecycler(R.id.home_genre_list, homeAdapter, homeModel.genres) } override val listPopupProvider: (Int) -> String diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt index 887a75886..0d24dfc71 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.home.list import android.annotation.SuppressLint +import android.view.LayoutInflater import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -27,36 +28,43 @@ import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.applySpans /** * A Base [Fragment] implementing the base features shared across all list fragments in the home UI. * @author OxygenCobalt */ -abstract class HomeListFragment : Fragment() { - protected val homeModel: HomeViewModel by activityViewModels() - protected val playbackModel: PlaybackViewModel by activityViewModels() - +abstract class HomeListFragment : ViewBindingFragment() { /** The popup provider to use for the fast scroller view. */ abstract val listPopupProvider: (Int) -> String + protected val homeModel: HomeViewModel by activityViewModels() + protected val playbackModel: PlaybackViewModel by activityViewModels() + protected fun setupRecycler( @IdRes uniqueId: Int, - binding: FragmentHomeListBinding, homeAdapter: HomeAdapter, homeData: LiveData>, ) { - binding.homeRecycler.apply { + requireBinding().homeRecycler.apply { id = uniqueId adapter = homeAdapter setHasFixedSize(true) applySpans() popupProvider = listPopupProvider - onDragListener = { dragging -> homeModel.updateFastScrolling(dragging) } + onDragListener = homeModel::updateFastScrolling } - homeData.observe(viewLifecycleOwner) { data -> homeAdapter.updateData(data) } + homeData.observe(viewLifecycleOwner, homeAdapter::updateData) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + FragmentHomeListBinding.inflate(inflater) + + override fun onDestroyBinding(binding: FragmentHomeListBinding) { + homeModel.updateFastScrolling(false) } abstract class HomeAdapter : 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 4ee24dba9..745dd880e 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 @@ -18,7 +18,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import org.oxycblt.auxio.R @@ -35,22 +34,9 @@ import org.oxycblt.auxio.ui.sliceArticle * @author */ class SongListFragment : HomeListFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentHomeListBinding.inflate(layoutInflater) - - // / --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - - val adapter = SongsAdapter(doOnClick = { song -> playbackModel.playSong(song) }, ::newMenu) - - setupRecycler(R.id.home_song_list, binding, adapter, homeModel.songs) - - return binding.root + override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { + val homeAdapter = SongsAdapter(doOnClick = playbackModel::playSong, ::newMenu) + setupRecycler(R.id.home_song_list, homeAdapter, homeModel.songs) } override val listPopupProvider: (Int) -> String 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 b303c307b..2ba910544 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 @@ -19,15 +19,13 @@ package org.oxycblt.auxio.home.tabs import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.recyclerview.widget.ItemTouchHelper import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.settings.SettingsManager -import org.oxycblt.auxio.ui.LifecycleDialog +import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD /** @@ -35,65 +33,11 @@ import org.oxycblt.auxio.util.logD * serializes it's state instead of * @author OxygenCobalt */ -class TabCustomizeDialog : LifecycleDialog() { +class TabCustomizeDialog : ViewBindingDialogFragment() { private val settingsManager = SettingsManager.getInstance() private var pendingTabs = settingsManager.libTabs - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = DialogTabsBinding.inflate(inflater) - - if (savedInstanceState != null) { - // Restore any pending tab configurations - val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) - if (tabs != null) { - pendingTabs = tabs - } - } - - // Set up adapter & drag callback - val callback = TabDragCallback { pendingTabs } - val helper = ItemTouchHelper(callback) - val tabAdapter = - TabAdapter( - helper, - getTabs = { pendingTabs }, - onTabSwitch = { tab -> - // Don't find the specific tab [Which might be outdated due to the nature - // of how ViewHolders are bound], but instead simply look for the mode in - // the list of pending tabs and update that instead. - val index = pendingTabs.indexOfFirst { it.mode == tab.mode } - if (index != -1) { - val curTab = pendingTabs[index] - logD("Updating tab $curTab to $tab") - pendingTabs[index] = - when (curTab) { - is Tab.Visible -> Tab.Invisible(curTab.mode) - is Tab.Invisible -> Tab.Visible(curTab.mode) - } - } - - (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) - .isEnabled = pendingTabs.filterIsInstance().isNotEmpty() - }) - - callback.addTabAdapter(tabAdapter) - - binding.tabRecycler.apply { - adapter = tabAdapter - helper.attachToRecyclerView(this) - } - - return binding.root - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs)) - } + override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { builder.setTitle(R.string.set_lib_tabs) @@ -107,6 +51,52 @@ class TabCustomizeDialog : LifecycleDialog() { builder.setNegativeButton(android.R.string.cancel, null) } + override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { + if (savedInstanceState != null) { + // Restore any pending tab configurations + val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) + if (tabs != null) { + pendingTabs = tabs + } + } + + // Set up adapter & drag callback + val callback = TabDragCallback { pendingTabs } + val helper = ItemTouchHelper(callback) + val tabAdapter = TabAdapter(helper, getTabs = { pendingTabs }, onTabSwitch = ::moveTabs) + + callback.addTabAdapter(tabAdapter) + + binding.tabRecycler.apply { + adapter = tabAdapter + helper.attachToRecyclerView(this) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs)) + } + + private fun moveTabs(tab: Tab) { + // Don't find the specific tab [Which might be outdated due to the nature + // of how ViewHolders are bound], but instead simply look for the mode in + // the list of pending tabs and update that instead. + val index = pendingTabs.indexOfFirst { it.mode == tab.mode } + if (index != -1) { + val curTab = pendingTabs[index] + logD("Updating tab $curTab to $tab") + pendingTabs[index] = + when (curTab) { + is Tab.Visible -> Tab.Invisible(curTab.mode) + is Tab.Invisible -> Tab.Visible(curTab.mode) + } + } + + (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = + pendingTabs.filterIsInstance().isNotEmpty() + } + companion object { const val TAG = BuildConfig.APPLICATION_ID + ".tag.TAB_CUSTOMIZE" const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS" diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt index 6296ba728..2e4672deb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt @@ -22,8 +22,6 @@ import android.os.Bundle import android.os.Environment import android.provider.DocumentsContract import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible @@ -33,7 +31,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogExcludedBinding import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.LifecycleDialog +import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -42,27 +40,29 @@ import org.oxycblt.auxio.util.showToast * Dialog that manages the currently excluded directories. * @author OxygenCobalt */ -class ExcludedDialog : LifecycleDialog() { +class ExcludedDialog : ViewBindingDialogFragment() { private val excludedModel: ExcludedViewModel by viewModels { ExcludedViewModel.Factory(requireContext()) } private val playbackModel: PlaybackViewModel by activityViewModels() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = DialogExcludedBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater) + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder.setTitle(R.string.set_excluded) + + // Don't set the click listener here, we do some custom black magic in onCreateView instead. + builder.setNeutralButton(R.string.lbl_add, null) + builder.setPositiveButton(R.string.lbl_save, null) + builder.setNegativeButton(android.R.string.cancel, null) + } + + override fun onBindingCreated(binding: DialogExcludedBinding, savedInstanceState: Bundle?) { val adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) } - val launcher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath) - // --- UI SETUP --- - binding.excludedRecycler.adapter = adapter // Now that the dialog exists, we get the view manually when the dialog is shown @@ -90,23 +90,14 @@ class ExcludedDialog : LifecycleDialog() { // --- VIEWMODEL SETUP --- - excludedModel.paths.observe(viewLifecycleOwner) { paths -> - adapter.submitList(paths) - binding.excludedEmpty.isVisible = paths.isEmpty() - } + excludedModel.paths.observe(viewLifecycleOwner) { paths -> updatePaths(paths, adapter) } logD("Dialog created") - - return binding.root } - override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.set_excluded) - - // Don't set the click listener here, we do some custom black magic in onCreateView instead. - builder.setNeutralButton(R.string.lbl_add, null) - builder.setPositiveButton(R.string.lbl_save, null) - builder.setNegativeButton(android.R.string.cancel, null) + private fun updatePaths(paths: MutableList, adapter: ExcludedEntryAdapter) { + adapter.submitList(paths) + requireBinding().excludedEmpty.isVisible = paths.isEmpty() } private fun addDocTreePath(uri: Uri?) { @@ -147,17 +138,16 @@ class ExcludedDialog : LifecycleDialog() { return null } + private fun getRootPath(): String { + return Environment.getExternalStorageDirectory().absolutePath + } + private fun saveAndRestart() { excludedModel.save { playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() } } } - /** Get *just* the root path, nothing else is really needed. */ - private fun getRootPath(): String { - return Environment.getExternalStorageDirectory().absolutePath - } - companion object { const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED" } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index fae91f30d..b609887fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -20,11 +20,8 @@ package org.oxycblt.auxio.playback import android.os.Build import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.view.WindowInsets import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.google.android.material.color.MaterialColors import org.oxycblt.auxio.R @@ -33,22 +30,21 @@ import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.bindSongInfo import org.oxycblt.auxio.ui.BottomSheetLayout +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.systemBarInsetsCompat -class PlaybackBarFragment : Fragment() { +class PlaybackBarFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, + override fun onCreateBinding(inflater: LayoutInflater) = + FragmentPlaybackBarBinding.inflate(inflater) + + override fun onBindingCreated( + binding: FragmentPlaybackBarBinding, savedInstanceState: Bundle? - ): View { - val binding = FragmentPlaybackBarBinding.inflate(inflater) - - // -- UI SETUP --- - + ) { binding.root.apply { setOnClickListener { // This is a dumb and fragile hack but this fragment isn't part of the navigation @@ -115,11 +111,9 @@ class PlaybackBarFragment : Fragment() { binding.playbackPlayPause.isActivated = isPlaying } - binding.playbackProgressBar.progress = playbackModel.seconds.value!!.toInt() - playbackModel.seconds.observe(viewLifecycleOwner) { position -> + binding.playbackProgressBar.progress = playbackModel.positionSeconds.value!!.toInt() + playbackModel.positionSeconds.observe(viewLifecycleOwner) { position -> binding.playbackProgressBar.progress = position.toInt() } - - return binding.root } } 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 e96553d7f..3d801db14 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -32,6 +32,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.bindAlbumCover import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.toDuration import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.ui.BottomSheetLayout @@ -55,24 +57,22 @@ class PlaybackPanelFragment : private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() - override fun onCreateBinding(inflater: LayoutInflater): FragmentPlaybackPanelBinding { - return FragmentPlaybackPanelBinding.inflate(inflater) - } + override fun onCreateBinding(inflater: LayoutInflater) = + FragmentPlaybackPanelBinding.inflate(inflater) override fun onBindingCreated( binding: FragmentPlaybackPanelBinding, savedInstanceState: Bundle? ) { - val queueItem: MenuItem - // --- UI SETUP --- - binding.root.setOnApplyWindowInsetsListener { _, insets -> val bars = insets.systemBarInsetsCompat binding.root.updatePadding(top = bars.top, bottom = bars.bottom) insets } + val queueItem: MenuItem + binding.playbackToolbar.apply { setNavigationOnClickListener { navigateUp() } @@ -114,7 +114,6 @@ class PlaybackPanelFragment : } binding.playbackLoop.setOnClickListener { playbackModel.incrementLoopStatus() } - binding.playbackSkipPrev.setOnClickListener { playbackModel.skipPrev() } binding.playbackPlayPause.apply { @@ -124,69 +123,21 @@ class PlaybackPanelFragment : } binding.playbackSkipNext.setOnClickListener { playbackModel.skipNext() } - binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffleStatus() } // --- VIEWMODEL SETUP -- - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (song != null) { - logD("Updating song display to ${song.rawName}") - binding.playbackCover.bindAlbumCover(song) - binding.playbackSong.text = song.resolvedName - binding.playbackArtist.text = song.resolvedArtistName - binding.playbackAlbum.text = song.resolvedAlbumName + playbackModel.song.observe(viewLifecycleOwner, ::updateSong) + playbackModel.parent.observe(viewLifecycleOwner, ::updateParent) + playbackModel.positionSeconds.observe(viewLifecycleOwner, ::updatePosition) + playbackModel.loopMode.observe(viewLifecycleOwner, ::updateLoop) + playbackModel.isPlaying.observe(viewLifecycleOwner, ::updatePlayPause) + playbackModel.isShuffling.observe(viewLifecycleOwner, ::updateShuffle) - // Normally if a song had a duration - val seconds = song.seconds - binding.playbackDuration.text = seconds.toDuration(false) - binding.playbackSeekBar.apply { - valueTo = max(seconds, 1L).toFloat() - isEnabled = seconds > 0L - } - } - } - - playbackModel.parent.observe(viewLifecycleOwner) { parent -> - binding.playbackToolbar.subtitle = - parent?.resolvedName ?: getString(R.string.lbl_all_songs) - } - - playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> - binding.playbackShuffle.isActivated = isShuffling - } - - playbackModel.loopMode.observe(viewLifecycleOwner) { loopMode -> - val resId = - when (loopMode) { - LoopMode.NONE, null -> R.drawable.ic_loop - LoopMode.ALL -> R.drawable.ic_loop_on - LoopMode.TRACK -> R.drawable.ic_loop_one - } - - binding.playbackLoop.apply { - isActivated = loopMode != LoopMode.NONE - setImageResource(resId) - } - } - - playbackModel.seconds.observe(viewLifecycleOwner) { pos -> - // Don't update the progress while we are seeking, that will make the SeekBar jump - // around. - if (!binding.playbackSeconds.isActivated) { - binding.playbackSeekBar.value = pos.toFloat() - binding.playbackSeconds.text = pos.toDuration(true) - } - } - - playbackModel.nextUp.observe(viewLifecycleOwner) { + playbackModel.nextUp.observe(viewLifecycleOwner) { nextUp -> // The queue icon uses a selector that will automatically tint the icon as active or // inactive. We just need to set the flag. - queueItem.isEnabled = playbackModel.nextUp.value!!.isNotEmpty() - } - - playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying -> - binding.playbackPlayPause.isActivated = isPlaying + queueItem.isEnabled = nextUp.isNotEmpty() } detailModel.navToItem.observe(viewLifecycleOwner) { item -> @@ -205,20 +156,68 @@ class PlaybackPanelFragment : } override fun onStartTrackingTouch(slider: Slider) { - requireBinding().playbackSeconds.isActivated = true + requireBinding().playbackPosition.isActivated = true } override fun onStopTrackingTouch(slider: Slider) { - requireBinding().playbackSeconds.isActivated = false + requireBinding().playbackPosition.isActivated = false playbackModel.setPosition(slider.value.toLong()) } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { - requireBinding().playbackSeconds.text = value.toLong().toDuration(true) + requireBinding().playbackPosition.text = value.toLong().toDuration(true) } } + private fun updateSong(song: Song?) { + if (song == null) return + + val binding = requireBinding() + binding.playbackCover.bindAlbumCover(song) + binding.playbackSong.text = song.resolvedName + binding.playbackArtist.text = song.resolvedArtistName + binding.playbackAlbum.text = song.resolvedAlbumName + + // Normally if a song had a duration + val seconds = song.seconds + binding.playbackDuration.text = seconds.toDuration(false) + binding.playbackSeekBar.apply { + valueTo = max(seconds, 1L).toFloat() + isEnabled = seconds > 0L + } + } + + private fun updateParent(parent: MusicParent?) { + requireBinding().playbackToolbar.subtitle = + parent?.resolvedName ?: getString(R.string.lbl_all_songs) + } + + private fun updatePosition(position: Long) { + // Don't update the progress while we are seeking, that will make the SeekBar jump + // around. + val binding = requireBinding() + if (!binding.playbackPosition.isActivated) { + binding.playbackSeekBar.value = position.toFloat() + binding.playbackPosition.text = position.toDuration(true) + } + } + + private fun updateLoop(loopMode: LoopMode) { + requireBinding().playbackLoop.apply { + isActivated = loopMode != LoopMode.NONE + setImageResource(loopMode.icon) + } + } + + private fun updatePlayPause(isPlaying: Boolean) { + requireBinding().playbackPlayPause.isActivated = isPlaying + } + + private fun updateShuffle(isShuffling: Boolean) { + requireBinding().playbackShuffle.isActivated = isShuffling + } + private fun navigateUp() { // This is a dumb and fragile hack but this fragment isn't part of the navigation stack // so we can't really do much diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 099c12328..0bd228e70 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -58,7 +58,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { private val mIsPlaying = MutableLiveData(false) private val mIsShuffling = MutableLiveData(false) private val mLoopMode = MutableLiveData(LoopMode.NONE) - private val mSeconds = MutableLiveData(0L) + private val mPositionSeconds = MutableLiveData(0L) // Queue private val mNextUp = MutableLiveData(listOf()) @@ -82,8 +82,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { val loopMode: LiveData get() = mLoopMode /** The current playback position, in seconds */ - val seconds: LiveData - get() = mSeconds + val positionSeconds: LiveData + get() = mPositionSeconds /** The queue, without the previous items. */ val nextUp: LiveData> @@ -336,7 +336,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { } override fun onPositionUpdate(position: Long) { - mSeconds.value = position / 1000 + mPositionSeconds.value = position / 1000 } override fun onQueueUpdate(queue: List, index: Int) { 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 bf29fcf7e..6a8ced7f4 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 @@ -19,40 +19,32 @@ package org.oxycblt.auxio.playback.queue import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.ItemTouchHelper import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.logD /** * A [Fragment] that shows the queue and enables editing as well. * @author OxygenCobalt */ -class QueueFragment : Fragment() { +class QueueFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by activityViewModels() + private var lastShuffle: Boolean? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentQueueBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = FragmentQueueBinding.inflate(inflater) + + override fun onBindingCreated(binding: FragmentQueueBinding, savedInstanceState: Bundle?) { + // TODO: Merge ItemTouchHelper with QueueAdapter val callback = QueueDragCallback(playbackModel) val helper = ItemTouchHelper(callback) val queueAdapter = QueueAdapter(helper) callback.addQueueAdapter(queueAdapter) - var lastShuffle = playbackModel.isShuffling.value - - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - binding.queueToolbar.setNavigationOnClickListener { findNavController().navigateUp() } binding.queueRecycler.apply { @@ -63,15 +55,7 @@ class QueueFragment : Fragment() { // --- VIEWMODEL SETUP ---- - playbackModel.nextUp.observe(viewLifecycleOwner) { queue -> - if (queue.isEmpty()) { - findNavController().navigateUp() - return@observe - } - - queueAdapter.submitList(queue.toMutableList()) - } - + lastShuffle = playbackModel.isShuffling.value playbackModel.isShuffling.observe(viewLifecycleOwner) { isShuffling -> // Try to prevent the queue adapter from going spastic during reshuffle events // by just scrolling back to the top. @@ -82,6 +66,13 @@ class QueueFragment : Fragment() { } } - return binding.root + playbackModel.nextUp.observe(viewLifecycleOwner) { queue -> + if (queue.isEmpty()) { + findNavController().navigateUp() + return@observe + } + + queueAdapter.submitList(queue.toMutableList()) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt index 4a8e9f92f..f93a5d57f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/LoopMode.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.playback.state import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R /** * Enum that determines the playback repeat mode. @@ -37,6 +38,14 @@ enum class LoopMode { } } + val icon: Int + get() = + when (this) { + NONE -> R.drawable.ic_loop + ALL -> R.drawable.ic_loop_on + TRACK -> R.drawable.ic_loop_one + } + /** * Convert the LoopMode to an int constant that is saved in PlaybackStateDatabase * @return The int constant for this mode 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 2644d90b2..d74656d94 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 @@ -72,6 +72,9 @@ import org.oxycblt.auxio.widgets.WidgetProvider * This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], so * therefore there's no need to bind to it to deliver commands. * @author OxygenCobalt + * + * TODO: Move all external exposal from passing around PlaybackStateManager to passing around the + * MediaMetadata instance. Generally makes it easier to encapsulate this class. */ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { @@ -199,6 +202,8 @@ class PlaybackService : // The service coroutines last job is to save the state to the DB, before terminating itself // FIXME: This is a terrible idea, move this to when the user closes the notification + // FIXME: Why not also encourage the user to disable battery optimizations while were + // at it? Would help prevent state saving issues to an extent. serviceScope.launch { playbackManager.saveStateToDatabase(this@PlaybackService) serviceJob.cancel() 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 dffe98ae1..15887b1c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -19,9 +19,8 @@ package org.oxycblt.auxio.search import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import androidx.core.view.isInvisible import androidx.core.view.postDelayed import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment @@ -35,10 +34,11 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Header +import org.oxycblt.auxio.music.Item import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.util.applySpans import org.oxycblt.auxio.util.getSystemServiceSafe @@ -48,41 +48,26 @@ import org.oxycblt.auxio.util.logD * A [Fragment] that allows for the searching of the entire music library. * @author OxygenCobalt */ -class SearchFragment : Fragment() { +class SearchFragment : ViewBindingFragment() { // SearchViewModel is only scoped to this Fragment private val searchModel: SearchViewModel by viewModels() private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() private var launchedKeyboard = false - private var mustScrollUp = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentSearchBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater) + override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) { val imm = requireContext().getSystemServiceSafe(InputMethodManager::class) val searchAdapter = SearchAdapter(doOnClick = { item -> onItemSelection(item, imm) }, ::newMenu) + // --- UI SETUP -- - binding.lifecycleOwner = viewLifecycleOwner - binding.searchToolbar.apply { - val itemId = - when (searchModel.filterMode) { - DisplayMode.SHOW_SONGS -> R.id.option_filter_songs - DisplayMode.SHOW_ALBUMS -> R.id.option_filter_albums - DisplayMode.SHOW_ARTISTS -> R.id.option_filter_artists - DisplayMode.SHOW_GENRES -> R.id.option_filter_genres - null -> R.id.option_filter_all - } - - menu.findItem(itemId).isChecked = true + menu.findItem(searchModel.filterMode?.itemId ?: R.id.option_filter_all).isChecked = true setNavigationOnClickListener { imm.hide() @@ -102,7 +87,6 @@ class SearchFragment : Fragment() { binding.searchEditText.apply { addTextChangedListener { text -> - mustScrollUp = true // Run the search with the updated text as the query searchModel.search(text?.toString() ?: "") } @@ -118,51 +102,50 @@ class SearchFragment : Fragment() { binding.searchRecycler.apply { adapter = searchAdapter - applySpans { pos -> searchAdapter.currentList[pos] is Header } } // --- VIEWMODEL SETUP --- searchModel.searchResults.observe(viewLifecycleOwner) { results -> - searchAdapter.submitList(results) { - // I would make it so that the position is only scrolled back to the top when - // the query actually changes instead of one every re-creation event, but sadly - // that doesn't seem possible. - binding.searchRecycler.scrollToPosition(0) - } - - if (results.isEmpty()) { - binding.searchRecycler.visibility = View.INVISIBLE - } else { - binding.searchRecycler.visibility = View.VISIBLE - } + updateResults(results, searchAdapter) } detailModel.navToItem.observe(viewLifecycleOwner) { item -> - findNavController() - .navigate( - when (item) { - is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id) - is Album -> SearchFragmentDirections.actionShowAlbum(item.id) - is Artist -> SearchFragmentDirections.actionShowArtist(item.id) - else -> return@observe - }) - + handleNavigation(item) imm.hide() } - - logD("Fragment created") - - return binding.root } override fun onResume() { super.onResume() - searchModel.setNavigating(false) } + private fun updateResults(results: List, searchAdapter: SearchAdapter) { + val binding = requireBinding() + + searchAdapter.submitList(results) { + // I would make it so that the position is only scrolled back to the top when + // the query actually changes instead of once every re-creation event, but sadly + // that doesn't seem possible. + binding.searchRecycler.scrollToPosition(0) + } + + binding.searchRecycler.isInvisible = results.isEmpty() + } + + private fun handleNavigation(item: Music?) { + findNavController() + .navigate( + when (item) { + is Song -> SearchFragmentDirections.actionShowAlbum(item.album.id) + is Album -> SearchFragmentDirections.actionShowAlbum(item.id) + is Artist -> SearchFragmentDirections.actionShowArtist(item.id) + else -> return + }) + } + private fun InputMethodManager.hide() { hideSoftInputFromWindow(requireView().windowToken, InputMethodManager.HIDE_NOT_ALWAYS) } 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 cc95de2ee..f4d43a158 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -23,11 +23,8 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.core.net.toUri import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -35,6 +32,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentAboutBinding import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.systemBarInsetsCompat @@ -43,18 +41,14 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * A [BottomSheetDialogFragment] that shows Auxio's about screen. * @author OxygenCobalt */ -class AboutFragment : Fragment() { +class AboutFragment : ViewBindingFragment() { private val homeModel: HomeViewModel by activityViewModels() - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentAboutBinding.inflate(layoutInflater) + override fun onCreateBinding(inflater: LayoutInflater) = FragmentAboutBinding.inflate(inflater) - binding.aboutContents.setOnApplyWindowInsetsListener { _, insets -> - binding.aboutContents.updatePadding(bottom = insets.systemBarInsetsCompat.bottom) + override fun onBindingCreated(binding: FragmentAboutBinding, savedInstanceState: Bundle?) { + binding.aboutContents.setOnApplyWindowInsetsListener { view, insets -> + view.updatePadding(bottom = insets.systemBarInsetsCompat.bottom) insets } @@ -68,10 +62,6 @@ class AboutFragment : Fragment() { homeModel.songs.observe(viewLifecycleOwner) { songs -> binding.aboutSongCount.text = getString(R.string.fmt_songs_loaded, songs.size) } - - logD("Dialog created") - - return binding.root } /** Go through the process of opening a [link] in a browser. */ @@ -100,8 +90,7 @@ class AboutFragment : Fragment() { requireContext() .packageManager .resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) - ?.activityInfo - ?.packageName + ?.run { activityInfo.packageName } if (pkgName != null) { if (pkgName == "android") { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt index f6dd15587..8df14fa6b 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt @@ -19,30 +19,21 @@ package org.oxycblt.auxio.settings import android.os.Bundle import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.databinding.FragmentSettingsBinding +import org.oxycblt.auxio.ui.ViewBindingFragment /** * A container [Fragment] for the settings menu. * @author OxygenCobalt */ -class SettingsFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentSettingsBinding.inflate(inflater) - - binding.settingsToolbar.apply { - setNavigationOnClickListener { findNavController().navigateUp() } - } +class SettingsFragment : ViewBindingFragment() { + override fun onCreateBinding(inflater: LayoutInflater) = + FragmentSettingsBinding.inflate(inflater) + override fun onBindingCreated(binding: FragmentSettingsBinding, savedInstanceState: Bundle?) { + binding.settingsToolbar.setNavigationOnClickListener { findNavController().navigateUp() } binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view - - return binding.root } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt index 4ec9768a5..5e2edb4ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt @@ -57,12 +57,12 @@ class SettingsListFragment : PreferenceFragmentCompat() { preferenceManager.onDisplayPreferenceDialogListener = this preferenceScreen.children.forEach(::recursivelyHandlePreference) + // Make the RecycleBiew edge-to-edge capable view.findViewById(androidx.preference.R.id.recycler_view).apply { clipToPadding = false setOnApplyWindowInsetsListener { _, insets -> updatePadding(bottom = insets.systemBarInsetsCompat.bottom) - insets } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPrefDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPrefDialog.kt index abfa10492..231fcc7ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPrefDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/pref/IntListPrefDialog.kt @@ -17,15 +17,18 @@ package org.oxycblt.auxio.settings.pref +import android.app.Dialog import android.os.Bundle -import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.ui.LifecycleDialog /** The dialog shown whenever an [IntListPreference] is shown. */ -class IntListPrefDialog : LifecycleDialog() { - override fun onConfigDialog(builder: AlertDialog.Builder) { +class IntListPrefDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireActivity(), theme) + // Since we have to store the preference key as an argument, we have to find the // preference we need to use manually. val pref = @@ -41,6 +44,8 @@ class IntListPrefDialog : LifecycleDialog() { } builder.setNegativeButton(android.R.string.cancel, null) + + return builder.create() } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt index a20ede0bf..c47fcd89b 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetLayout.kt @@ -380,6 +380,11 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : // We kind of do a reverse-measure to figure out how we should inset this view. // Find how much space is lost by the panel and then combine that with the // bottom inset to find how much space we should apply. + // There is a slight shortcoming to this. If the playback bar has a height of + // zero (usually due to delays with fragment inflation), then it is assumed to + // not apply any window insets at all, which results in scroll desynchronization on + // certain views. This is considered tolerable as the other options are to convert + // the playback fragments to views, which is not nice. val bars = insets.systemBarInsetsCompat val consumedByPanel = computePanelTopPosition(panelOffset) - measuredHeight val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DisplayMode.kt b/app/src/main/java/org/oxycblt/auxio/ui/DisplayMode.kt index 33608f8d9..990d3cb88 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/DisplayMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/DisplayMode.kt @@ -50,6 +50,15 @@ enum class DisplayMode { SHOW_GENRES -> R.drawable.ic_genre } + val itemId: Int + get() = + when (this) { + SHOW_SONGS -> R.drawable.ic_song + SHOW_ALBUMS -> R.drawable.ic_album + SHOW_ARTISTS -> R.drawable.ic_artist + SHOW_GENRES -> R.drawable.ic_genre + } + val intCode: Int get() = when (this) { diff --git a/app/src/main/java/org/oxycblt/auxio/ui/LifecycleDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/LifecycleDialog.kt deleted file mode 100644 index 46abbd227..000000000 --- a/app/src/main/java/org/oxycblt/auxio/ui/LifecycleDialog.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.ui - -import android.app.Dialog -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** - * A wrapper around [DialogFragment] that allows the usage of the standard Auxio lifecycle override - * [onCreateView] and [onDestroyView], but with a proper dialog being created. - */ -abstract class LifecycleDialog : AppCompatDialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireActivity(), theme) - onConfigDialog(builder) - return builder.create() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - (requireDialog() as AlertDialog).setView(view) - } - - protected open fun onConfigDialog(builder: AlertDialog.Builder) {} -} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt index 9825a3b2d..f4963cb03 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/Sort.kt @@ -98,7 +98,7 @@ sealed class Sort(open val isAscending: Boolean) { } override fun ascending(newIsAscending: Boolean): Sort { - return ByName(isAscending) + return ByName(newIsAscending) } } @@ -239,21 +239,17 @@ sealed class Sort(open val isAscending: Boolean) { } class NameComparator : Comparator { - override fun compare(a: T?, b: T?): Int { - if (a == null && b != null) return -1 // -1 -> a < b - if (a == null && b == null) return 0 // 0 -> 0 = b - if (a != null && b == null) return 1 // 1 -> a > b - - return a!!.resolvedName + override fun compare(a: T, b: T): Int { + return a.resolvedName .sliceArticle() - .compareTo(b!!.resolvedName.sliceArticle(), ignoreCase = true) + .compareTo(b.resolvedName.sliceArticle(), ignoreCase = true) } } class NullableComparator> : Comparator { override fun compare(a: T?, b: T?): Int { if (a == null && b != null) return -1 // -1 -> a < b - if (a == null && b == null) return 0 // 0 -> 0 = b + if (a == null && b == null) return 0 // 0 -> a = b if (a != null && b == null) return 1 // 1 -> a > b return a!!.compareTo(b!!) } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt new file mode 100644 index 000000000..85fb0bcd1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.ui + +import android.app.Dialog +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.dialog.MaterialAlertDialogBuilder +import org.oxycblt.auxio.util.logD + +abstract class ViewBindingDialogFragment : DialogFragment() { + private var mBinding: T? = null + + protected abstract fun onCreateBinding(inflater: LayoutInflater): T + protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {} + protected open fun onDestroyBinding(binding: T) {} + protected open fun onConfigDialog(builder: AlertDialog.Builder) {} + + protected val binding: T? + get() = mBinding + + protected fun requireBinding(): T { + return requireNotNull(mBinding) { + "ViewBinding was not available, as the fragment was not in a valid state" + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = onCreateBinding(inflater).also { mBinding = it }.root + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireActivity(), theme).run { + onConfigDialog(this) + create() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBindingCreated(requireBinding(), savedInstanceState) + (requireDialog() as AlertDialog).setView(view) + logD("Fragment created") + } + + override fun onDestroyView() { + super.onDestroyView() + onDestroyBinding(requireBinding()) + mBinding = null + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt index 668db285d..00d0a0d8d 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt @@ -23,23 +23,35 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding +import org.oxycblt.auxio.util.logD /** A fragment enabling ViewBinding inflation and usage across the fragment lifecycle. */ abstract class ViewBindingFragment : Fragment() { private var mBinding: T? = null - abstract fun onCreateBinding(inflater: LayoutInflater): T - abstract fun onBindingCreated(binding: T, savedInstanceState: Bundle?) - abstract fun onDestroyBinding(binding: T) + protected abstract fun onCreateBinding(inflater: LayoutInflater): T + protected open fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {} + protected open fun onDestroyBinding(binding: T) {} + + protected val binding: T? + get() = mBinding + + protected fun requireBinding(): T { + return requireNotNull(mBinding) { + "ViewBinding was not available, as the fragment was not in a valid state" + } + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val binding = onCreateBinding(inflater).also { mBinding = it } - onBindingCreated(binding, savedInstanceState) - return binding.root + ): View = onCreateBinding(inflater).also { mBinding = it }.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBindingCreated(requireBinding(), savedInstanceState) + logD("Fragment created") } override fun onDestroyView() { @@ -47,13 +59,4 @@ abstract class ViewBindingFragment : Fragment() { onDestroyBinding(requireBinding()) mBinding = null } - - protected val binding: T? - get() = mBinding - - protected fun requireBinding(): T { - return requireNotNull(mBinding) { - "ViewBinding was not available, as the fragment is not in a valid state" - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt index f1c722445..c8a9966fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt @@ -22,7 +22,6 @@ import android.graphics.Insets import android.graphics.Rect import android.graphics.drawable.Drawable import android.os.Build -import android.util.Log import android.view.View import android.view.WindowInsets import androidx.annotation.ColorRes @@ -93,9 +92,6 @@ private fun isUnderImpl( if (viewSize >= minTouchTargetSize) { return position >= viewStart && position < viewEnd } - - Log.d("Auxio.ViewUtil", "isInTouchTarget: $minTouchTargetSize") - var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2 if (touchTargetStart < 0) { diff --git a/app/src/main/res/layout-land/fragment_playback_panel.xml b/app/src/main/res/layout-land/fragment_playback_panel.xml index fbbd54e49..cdb6b6573 100644 --- a/app/src/main/res/layout-land/fragment_playback_panel.xml +++ b/app/src/main/res/layout-land/fragment_playback_panel.xml @@ -98,14 +98,14 @@ app:thumbRadius="@dimen/slider_thumb_radius" /> @@ -117,7 +117,7 @@ android:layout_marginEnd="@dimen/spacing_medium" android:textAppearance="@style/TextAppearance.Auxio.BodyMedium" android:textColor="?android:attr/textColorSecondary" - app:layout_constraintEnd_toEndOf="@+id/playback_seek_bar" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/playback_seek_bar" tools:text="16:16" /> @@ -125,12 +125,12 @@ android:id="@+id/playback_loop" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_medium" android:contentDescription="@string/desc_change_loop" android:src="@drawable/ic_loop" app:hasIndicator="true" + android:layout_marginStart="@dimen/spacing_medium" app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev" - app:layout_constraintStart_toStartOf="@+id/playback_seek_bar" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" /> diff --git a/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml index c4ab0cd09..e595a90cf 100644 --- a/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp-land/fragment_playback_panel.xml @@ -4,22 +4,6 @@ xmlns:tools="http://schemas.android.com/tools" tools:context=".playback.PlaybackPanelFragment"> - - - - - - - - - - - - - - - - - - - @@ -78,8 +59,6 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/spacing_medium" android:layout_marginEnd="@dimen/spacing_medium" - android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}" - android:text="@{song.resolvedArtistName}" app:layout_constraintBottom_toTopOf="@+id/playback_album" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" @@ -94,8 +73,6 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/spacing_medium" android:layout_marginEnd="@dimen/spacing_medium" - android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album)}" - android:text="@{song.resolvedAlbumName}" app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" @@ -121,7 +98,7 @@ app:thumbRadius="@dimen/slider_thumb_radius" /> @@ -109,12 +109,12 @@ android:id="@+id/playback_loop" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_medium" android:contentDescription="@string/desc_change_loop" android:src="@drawable/ic_loop" app:hasIndicator="true" + android:layout_marginStart="@dimen/spacing_medium" app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev" - app:layout_constraintStart_toStartOf="@+id/playback_seek_bar" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" /> diff --git a/app/src/main/res/layout/view_seek_bar.xml b/app/src/main/res/layout/view_seek_bar.xml index e864a8780..67d599dce 100644 --- a/app/src/main/res/layout/view_seek_bar.xml +++ b/app/src/main/res/layout/view_seek_bar.xml @@ -26,7 +26,7 @@ app:layout_constraintStart_toStartOf="parent" /> 16dp 6dp - 12dp + 16dp 88dp 128dp diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml index 447d24389..d592f2350 100644 --- a/app/src/main/res/values/styles_android.xml +++ b/app/src/main/res/values/styles_android.xml @@ -43,6 +43,7 @@