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