diff --git a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java index 91d13bca6..d65db6c79 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java @@ -83,7 +83,7 @@ import java.util.Map; * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. * - * Modified at several points by OxygenCobalt to work around miscellaneous insanity. + * Modified at several points by Alexander Capehart to work around miscellaneous issues. */ public class NeoBottomSheetBehavior extends CoordinatorLayout.Behavior { diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 732be4436..6b246989a 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -27,11 +27,15 @@ import coil.ImageLoaderFactory import coil.request.CachePolicy import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.ArtistImageFetcher -import org.oxycblt.auxio.image.extractor.CrossfadeTransitionFactory +import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFractory import org.oxycblt.auxio.image.extractor.GenreImageFetcher import org.oxycblt.auxio.image.extractor.MusicKeyer import org.oxycblt.auxio.settings.Settings +/** + * Auxio. + * @author Alexander Capehart (OxygenCobalt) + */ class AuxioApp : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() @@ -57,14 +61,17 @@ class AuxioApp : Application(), ImageLoaderFactory { override fun newImageLoader() = ImageLoader.Builder(applicationContext) .components { + // Add fetchers for Music components to make them usable with ImageRequest + add(MusicKeyer()) add(AlbumCoverFetcher.SongFactory()) add(AlbumCoverFetcher.AlbumFactory()) add(ArtistImageFetcher.Factory()) add(GenreImageFetcher.Factory()) - add(MusicKeyer()) } - .transitionFactory(CrossfadeTransitionFactory()) - .diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching + // Use our own crossfade with error drawable support + .transitionFactory(ErrorCrossfadeTransitionFractory()) + // Not downloading anything, so no disk-caching + .diskCachePolicy(CachePolicy.DISABLED) .build() companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index e62d4222d..a32897ba3 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -52,7 +52,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * * TODO: Unit testing * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MainActivity : AppCompatActivity() { private val playbackModel: PlaybackViewModel by androidViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 2a44c3193..be191f211 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -48,7 +48,7 @@ import org.oxycblt.auxio.util.* /** * A wrapper around the home fragment that shows the playback fragment and controls the more * high-level navigation features. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MainFragment : ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener { 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 fe0b3613d..9a71c9489 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -17,12 +17,10 @@ package org.oxycblt.auxio.detail -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View -import androidx.core.view.children import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -44,34 +42,25 @@ import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A fragment that shows information for a particular [Album]. - * @author OxygenCobalt + * A [ListFragment] that shows information about an [Album]. + * @author Alexander Capehart (OxygenCobalt) */ -class AlbumDetailFragment : ListFragment() { +class AlbumDetailFragment : ListFragment(), AlbumDetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() - + // Information about what album to display is initially within the navigation arguments + // as a UID, as that is the only safe way to parcel an album. private val args: AlbumDetailFragmentArgs by navArgs() - private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } - - private val detailAdapter = - AlbumDetailAdapter( - AlbumDetailAdapter.Callback( - ::handleClick, - ::handleOpenItemMenu, - ::handleSelect, - ::handlePlay, - ::handleShuffle, - ::handleOpenSortMenu, - ::handleArtistNavigation)) + private val detailAdapter = AlbumDetailAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Detail transitions are always on the X axis. Shared element transitions are more + // semantically correct, but are also too buggy to be sensible. enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) @@ -80,62 +69,91 @@ class AlbumDetailFragment : ListFragment() { override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + override fun getSelectionToolbar(binding: FragmentDetailBinding) = + binding.detailSelectionToolbar + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - setupSelectionToolbar(binding.detailSelectionToolbar) + super.onBindingCreated(binding, savedInstanceState) binding.detailToolbar.apply { inflateMenu(R.menu.menu_album_detail) setNavigationOnClickListener { findNavController().navigateUp() } - setOnMenuItemClickListener { - handleDetailMenuItem(it) - true - } + setOnMenuItemClickListener(this@AlbumDetailFragment) } binding.detailRecycler.adapter = detailAdapter // -- VIEWMODEL SETUP --- + // DetailViewModel handles most initialization from the navigation argument. detailModel.setAlbumUid(args.albumUid) - collectImmediately(detailModel.currentAlbum, ::updateItem) - collectImmediately(detailModel.albumData, detailAdapter::submitList) + collectImmediately(detailModel.currentAlbum, ::updateAlbum) + collectImmediately(detailModel.albumList, detailAdapter::submitList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) - collectImmediately(selectionModel.selected, ::handleSelection) + collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) + binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null } + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value) + return when (item.itemId) { + R.id.action_play_next -> { + playbackModel.playNext(currentAlbum) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(currentAlbum) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_go_artist -> { + onNavigateToParentArtist() + true + } + else -> false + } + } + override fun onRealClick(music: Music) { - check(music is Song) { "Unexpected datatype: ${music::class.java}"} - when (settings.detailPlaybackMode) { + check(music is Song) { "Unexpected datatype: ${music::class.java}" } + when (val mode = Settings(requireContext()).detailPlaybackMode) { + // "Play from shown item" and "Play from album" functionally have the same + // behavior since a song can only have one album. null, MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) MusicMode.SONGS -> playbackModel.playFromAll(music) MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") + else -> error("Unexpected playback mode: $mode") } } - private fun handleOpenItemMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" } openMusicMenu(anchor, R.menu.menu_album_song_actions, item) } - private fun handlePlay() { + override fun onPlay() { playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value)) } - private fun handleShuffle() { + override fun onShuffle() { playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) } - private fun handleOpenSortMenu(anchor: View) { + override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_album_sort) { val sort = detailModel.albumSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true @@ -153,28 +171,17 @@ class AlbumDetailFragment : ListFragment() { } } - private fun handleArtistNavigation() { + override fun onNavigateToParentArtist() { navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artists) } - private fun handleDetailMenuItem(item: MenuItem) { - when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(unlikelyToBeNull(detailModel.currentAlbum.value)) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentAlbum.value)) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_go_artist -> { - handleArtistNavigation() - } - } - } - - private fun updateItem(album: Album?) { + /** + * Update the currently displayed [Album]. + * @param album The new [Album] to display. Null if there is no longer one. + */ + private fun updateAlbum(album: Album?) { if (album == null) { + // Album we were showing no longer exists. findNavController().navigateUp() return } @@ -182,18 +189,13 @@ class AlbumDetailFragment : ListFragment() { requireBinding().detailToolbar.title = album.resolveName(requireContext()) } + /** + * Update the current playback state in the context of the currently displayed [Album]. + * @param song The current [Song] playing. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - val binding = requireBinding() - - for (item in binding.detailToolbar.menu.children) { - // If there is no playback going in, any queue additions will be wiped as soon as - // something is played. Disable these actions when playback is going on so that - // it isn't possible to add anything during that time. - if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) { - item.isEnabled = song != null - } - } - if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { detailAdapter.setPlayingItem(song, isPlaying) } else { @@ -202,6 +204,10 @@ class AlbumDetailFragment : ListFragment() { } } + /** + * Handle a navigation event. + * @param item The [Music] to navigate to, null if there is no item. + */ private fun handleNavigation(item: Music?) { val binding = requireBinding() when (item) { @@ -244,17 +250,40 @@ class AlbumDetailFragment : ListFragment() { } } - /** Scroll to a [song]. */ + /** + * Scroll to a [Song] within the [Album] view, assuming that it is present. + * @param song The song to try to scroll to. + */ private fun scrollToItem(song: Song) { // Calculate where the item for the currently played song is - val pos = detailModel.albumData.value.indexOf(song) + val pos = detailModel.albumList.value.indexOf(song) if (pos != -1) { + // Only scroll if the song is within this album. val binding = requireBinding() binding.detailRecycler.post { + // Use a custom smooth scroller that will settle the item in the middle of + // the screen rather than the end. + // TODO: Can I apply this to the queue view? + val centerSmoothScroller = + object : LinearSmoothScroller(context) { + init { + targetPosition = pos + } + + override fun calculateDtToFit( + viewStart: Int, + viewEnd: Int, + boxStart: Int, + boxEnd: Int, + snapPreference: Int + ): Int = + (boxStart + (boxEnd - boxStart) / 2) - + (viewStart + (viewEnd - viewStart) / 2) + } + // Make sure to increment the position to make up for the detail header - binding.detailRecycler.layoutManager?.startSmoothScroll( - CenterSmoothScroller(requireContext(), pos)) + binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller) // If the recyclerview can scroll, its certain that it will have to scroll to // correctly center the playing item, so make sure that the Toolbar is lifted in @@ -264,28 +293,12 @@ class AlbumDetailFragment : ListFragment() { } } - private fun handleSelection(selected: List) { + /** + * Update the current item selection. + * @param selected The list of selected items. + */ + private fun updateSelection(selected: List) { detailAdapter.setSelectedItems(selected) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } - - /** - * [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to - * the top or bottom. - */ - private class CenterSmoothScroller(context: Context, target: Int) : - LinearSmoothScroller(context) { - init { - targetPosition = target - } - - override fun calculateDtToFit( - viewStart: Int, - viewEnd: Int, - boxStart: Int, - boxEnd: Int, - snapPreference: Int - ): Int = (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2) - } - } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index a162dc547..591555faa 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -31,6 +31,7 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.selection.SelectionToolbarOverlay import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music @@ -41,33 +42,26 @@ import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A fragment that shows information for a particular [Artist]. - * @author OxygenCobalt + * A [ListFragment] that shows information about an [Artist]. + * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailFragment : ListFragment() { +class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() - + // Information about what artist to display is initially within the navigation arguments + // as a UID, as that is the only safe way to parcel an artist. private val args: ArtistDetailFragmentArgs by navArgs() - private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } - private val detailAdapter = - ArtistDetailAdapter( - DetailAdapter.Callback( - ::handleClick, - ::handleOpenItemMenu, - ::handleSelect, - ::handlePlay, - ::handleShuffle, - ::handleOpenSortMenu)) + ArtistDetailAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Detail transitions are always on the X axis. Shared element transitions are more + // semantically correct, but are also too buggy to be sensible. enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) @@ -76,48 +70,73 @@ class ArtistDetailFragment : ListFragment() { override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + override fun getSelectionToolbar(binding: FragmentDetailBinding) = + binding.detailSelectionToolbar + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - setupSelectionToolbar(binding.detailSelectionToolbar) + super.onBindingCreated(binding, savedInstanceState) binding.detailToolbar.apply { inflateMenu(R.menu.menu_genre_artist_detail) setNavigationOnClickListener { findNavController().navigateUp() } - setOnMenuItemClickListener { - handleDetailMenuItem(it) - true - } + setOnMenuItemClickListener(this@ArtistDetailFragment) } binding.detailRecycler.adapter = detailAdapter // --- VIEWMODEL SETUP --- + // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtistUid(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateItem) - collectImmediately(detailModel.artistData, detailAdapter::submitList) + collectImmediately(detailModel.artistList, detailAdapter::submitList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) - collectImmediately(selectionModel.selected, ::handleSelection) + collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) + binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null } + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) + return when (item.itemId) { + R.id.action_play_next -> { + playbackModel.playNext(currentArtist) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_queue_add -> { + playbackModel.addToQueue(currentArtist) + requireContext().showToast(R.string.lng_queue_added) + true + } + else -> false + } + } + override fun onRealClick(music: Music) { when (music) { is Song -> { - when (settings.detailPlaybackMode) { + when (val mode = Settings(requireContext()).detailPlaybackMode) { + // "Play from selected item" and "Play from artist" differ, as the latter + // actually should show a picker choice in the case of multiple artists. null -> playbackModel.playFromArtist( music, unlikelyToBeNull(detailModel.currentArtist.value)) MusicMode.SONGS -> playbackModel.playFromAll(music) MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") + else -> error("Unexpected playback mode: $mode") } } is Album -> navModel.exploreNavigateTo(music) @@ -125,7 +144,7 @@ class ArtistDetailFragment : ListFragment() { } } - private fun handleOpenItemMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item) @@ -133,15 +152,15 @@ class ArtistDetailFragment : ListFragment() { } } - private fun handlePlay() { + override fun onPlay() { playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value)) } - private fun handleShuffle() { + override fun onShuffle() { playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value)) } - private fun handleOpenSortMenu(anchor: View) { + override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_artist_sort) { val sort = detailModel.artistSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true @@ -161,21 +180,13 @@ class ArtistDetailFragment : ListFragment() { } } - private fun handleDetailMenuItem(item: MenuItem) { - when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(unlikelyToBeNull(detailModel.currentArtist.value)) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentArtist.value)) - requireContext().showToast(R.string.lng_queue_added) - } - } - } - + /** + * Update the currently displayed [Artist] + * @param artist The new [Artist] to display. Null if there is no longer one. + */ private fun updateItem(artist: Artist?) { if (artist == null) { + // Artist we were showing no longer exists. findNavController().navigateUp() return } @@ -183,34 +194,50 @@ class ArtistDetailFragment : ListFragment() { requireBinding().detailToolbar.title = artist.resolveName(requireContext()) } + /** + * Update the current playback state in the context of the currently displayed [Artist]. + * @param song The current [Song] playing. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - var item: Item? = null + val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) + val playingItem = + when (parent) { + // Always highlight a playing album from this artist. + is Album -> parent + // If the parent is the artist itself, use the currently playing song. + currentArtist -> song + // Nothing is playing from this artist. + else -> null + } - if (parent is Album) { - item = parent - } - - if (parent is Artist && parent == unlikelyToBeNull(detailModel.currentArtist.value)) { - item = song - } - - detailAdapter.setPlayingItem(item, isPlaying) + detailAdapter.setPlayingItem(playingItem, isPlaying) } + /** + * Handle a navigation event. + * @param item The [Music] to navigate to, null if there is no item. + */ private fun handleNavigation(item: Music?) { val binding = requireBinding() when (item) { + // Songs should be shown in their album, not in their artist. is Song -> { logD("Navigating to another album") findNavController() .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid)) } + // Launch a new detail view for an album, even if it is part of + // this artist. is Album -> { logD("Navigating to another album") findNavController() .navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.uid)) } + // If the artist that should be navigated to is this artist, then + // scroll back to the top. Otherwise launch a new detail view. is Artist -> { if (item.uid == detailModel.currentArtist.value?.uid) { logD("Navigating to the top of this artist") @@ -227,7 +254,11 @@ class ArtistDetailFragment : ListFragment() { } } - private fun handleSelection(selected: List) { + /** + * Update the current item selection. + * @param selected The list of selected items. + */ + private fun updateSelection(selected: List) { detailAdapter.setSelectedItems(selected) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt new file mode 100644 index 000000000..47d2f132e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -0,0 +1,38 @@ +package org.oxycblt.auxio.detail + +import androidx.annotation.StringRes +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.storage.MimeType + +/** + * A header variation that displays a button to open a sort menu. + * @param titleRes The string resource to use as the header title + */ +data class SortHeader(@StringRes val titleRes: Int) : Item + +/** + * A header variation that delimits between disc groups. + * @param disc The disc number to be displayed on the header. + */ +data class DiscHeader(val disc: Int) : Item + +/** + * A [Song] extension that adds information about it's file properties. + * @param song The internal song + * @param properties The properties of the song file. Null if parsing is ongoing. + */ +data class DetailSong(val song: Song, val properties: Properties?) { + /** + * The properties of a [Song]'s file. + * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. + * @param sampleRateHz The sample rate, in hertz. + * @param resolvedMimeType The known mime type of the [Song] after it's file format was + * determined. + */ + data class Properties( + val bitrateKbps: Int?, + val sampleRateHz: Int?, + val resolvedMimeType: MimeType + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index 3c355789b..967699cd1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -22,8 +22,8 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup +import android.widget.TextView import androidx.annotation.AttrRes -import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.recyclerview.widget.LinearLayoutManager @@ -35,17 +35,23 @@ import org.oxycblt.auxio.shared.AuxioAppBarLayout import org.oxycblt.auxio.util.lazyReflectedField /** - * An [AuxioAppBarLayout] variant that also shows the name of the toolbar whenever the detail - * recyclerview is scrolled beyond it's first item (a.k.a the header). This is used instead of - * CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This - * just works. - * @author OxygenCobalt + * An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes + * beyond it's first item. + * + * This is intended for the detail views, in which the first item is the album/artist/genre header, + * and thus scrolling past them should make the toolbar show the name in order to give context on + * where the user currently is. + * + * This task should nominally be accomplished with CollapsingToolbarLayout, but I have not figured + * out how to get that working sensibly yet. + * + * @author Alexander Capehart (OxygenCobalt) */ class DetailAppBarLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : AuxioAppBarLayout(context, attrs, defStyleAttr) { - private var titleView: AppCompatTextView? = null + private var titleView: TextView? = null private var recycler: RecyclerView? = null private var titleShown: Boolean? = null @@ -56,18 +62,25 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr (layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context) } - private fun findTitleView(): AppCompatTextView { + private fun findTitleView(): TextView { val titleView = titleView if (titleView != null) { return titleView } + // Assume that we have a Toolbar with a detail_toolbar ID, as this view is only + // used within the detail layouts. val toolbar = findViewById(R.id.detail_toolbar) - // Reflect to get the actual title view to do transformations on - val newTitleView = TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as AppCompatTextView + // The Toolbar's title view is actually hidden. To avoid having to create our own + // title view, we just reflect into Toolbar and grab the hidden field. + val newTitleView = (TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply { + // We can never properly initialize the title view's state before draw time, + // so we just set it's alpha to 0f to produce a less jarring initialization + // animation.. + alpha = 0f + } - newTitleView.alpha = 0f this.titleView = newTitleView return newTitleView } @@ -78,6 +91,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return recycler } + // Use the scrolling view in order to find a RecyclerView to use. val newRecycler = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId) this.recycler = newRecycler return newRecycler @@ -85,7 +99,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private fun setTitleVisibility(visible: Boolean) { if (titleShown == visible) return - titleShown = visible val titleAnimator = titleAnimator @@ -94,6 +107,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr this.titleAnimator = null } + // Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with + // the title view's alpha instead of the AppBarLayout's elevation. val titleView = findTitleView() val from: Float val to: Float @@ -106,7 +121,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr to = 0f } - if (titleView.alpha == to) return + if (titleView.alpha == to) { + // Nothing to do + return + } this.titleAnimator = ValueAnimator.ofFloat(from, to).apply { @@ -136,13 +154,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr ) { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) - val appBar = child as DetailAppBarLayout - val recycler = appBar.findRecyclerView() + val appBarLayout = child as DetailAppBarLayout + val recycler = appBarLayout.findRecyclerView() - val showTitle = + // Title should be visible if we are no longer showing the top item + // (i.e the header) + appBarLayout.setTitleVisibility( (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0 - - appBar.setTitleVisibility(showTitle) + ) } } 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 e0ca3863c..be776e93e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -42,154 +42,307 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.settings.Settings -import org.oxycblt.auxio.util.application -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW -import org.oxycblt.auxio.util.unlikelyToBeNull +import org.oxycblt.auxio.util.* /** - * ViewModel that stores data for the detail fragments. This includes: - * - What item the fragment should be showing - * - The RecyclerView data for each fragment - * - The sorts for each type of data - * @author OxygenCobalt + * [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. + * Keeps track of the current item they are showing, sub-data to display, and configuration. + * Since this ViewModel requires a context, it must be instantiated [AndroidViewModel]'s Factory. + * @param application [Application] context required to initialize certain information. + * @author Alexander Capehart (OxygenCobalt) */ class DetailViewModel(application: Application) : AndroidViewModel(application), MusicStore.Callback { - data class DetailSong(val song: Song, val info: SongInfo?) - - data class SongInfo( - val bitrateKbps: Int?, - val sampleRate: Int?, - val resolvedMimeType: MimeType - ) + /** + * A simpler mapping of [ReleaseType] used for grouping and sorting songs. + * @param headerTitleRes The title string resource to use for a header created + * out of an instance of this enum. + */ + private enum class ReleaseTypeGrouping(@StringRes val headerTitleRes: Int) { + ALBUMS(R.string.lbl_albums), + EPS(R.string.lbl_eps), + SINGLES(R.string.lbl_singles), + COMPILATIONS(R.string.lbl_compilations), + SOUNDTRACKS(R.string.lbl_soundtracks), + MIXES(R.string.lbl_mixes), + MIXTAPES(R.string.lbl_mixtapes), + LIVE(R.string.lbl_live_group), + REMIXES(R.string.lbl_remix_group), + } private val musicStore = MusicStore.getInstance() private val settings = Settings(application) + private var currentSongJob: Job? = null + + // --- SONG --- + private val _currentSong = MutableStateFlow(null) + /** + * The current [Song] that should be displayed in the [Song] detail view. Null if there + * is no [Song]. + * TODO: De-couple Song and Properties? + */ val currentSong: StateFlow get() = _currentSong - private var currentSongJob: Job? = null + // --- ALBUM --- private val _currentAlbum = MutableStateFlow(null) + /** + * The current [Album] that should be displayed in the [Album] detail view. Null if there + * is no [Album]. + */ val currentAlbum: StateFlow get() = _currentAlbum private val _albumData = MutableStateFlow(listOf()) - val albumData: StateFlow> + /** + * The current list data derived from [currentAlbum], for use in the [Album] detail view. + */ + val albumList: StateFlow> get() = _albumData + /** + * The current [Sort] used for [Song]s in the [Album] detail view. + */ var albumSort: Sort get() = settings.detailAlbumSort set(value) { settings.detailAlbumSort = value - currentAlbum.value?.let(::refreshAlbumData) + // Refresh the album list to reflect the new sort. + currentAlbum.value?.let(::refreshAlbumList) } + // --- ARTIST --- + private val _currentArtist = MutableStateFlow(null) + /** + * The current [Artist] that should be displayed in the [Artist] detail view. Null if there + * is no [Artist]. + */ val currentArtist: StateFlow get() = _currentArtist private val _artistData = MutableStateFlow(listOf()) - val artistData: StateFlow> = _artistData + /** + * The current list derived from [currentArtist], for use in the [Artist] detail view. + */ + val artistList: StateFlow> = _artistData + /** + * The current [Sort] used for [Song]s in the [Artist] detail view. + */ var artistSort: Sort get() = settings.detailArtistSort set(value) { settings.detailArtistSort = value - currentArtist.value?.let(::refreshArtistData) + // Refresh the artist list to reflect the new sort. + currentArtist.value?.let(::refreshArtistList) } + // --- GENRE --- + private val _currentGenre = MutableStateFlow(null) + /** + * The current [Genre] that should be displayed in the [Genre] detail view. Null if there + * is no [Genre]. + */ val currentGenre: StateFlow get() = _currentGenre private val _genreData = MutableStateFlow(listOf()) - val genreData: StateFlow> = _genreData + /** + * The current list data derived from [currentGenre], for use in the [Genre] detail view. + */ + val genreList: StateFlow> = _genreData + /** + * The current [Sort] used for [Song]s in the [Genre] detail view. + */ var genreSort: Sort get() = settings.detailGenreSort set(value) { settings.detailGenreSort = value - currentGenre.value?.let(::refreshGenreData) + // Refresh the genre list to reflect the new sort. + currentGenre.value?.let(::refreshGenreList) } init { musicStore.addCallback(this) } + override fun onCleared() { + musicStore.removeCallback(this) + } + + override fun onLibraryChanged(library: MusicStore.Library?) { + if (library == null) { + // Nothing to do. + return + } + + // If we are showing any item right now, we will need to refresh it (and any information + // related to it) with the new library in order to keep it fresh. + + val song = currentSong.value + if (song != null) { + val newSong = library.sanitize(song.song) + if (newSong != null) { + loadDetailSong(newSong) + } else { + _currentSong.value = null + } + logD("Updated song to ${newSong}") + } + + val album = currentAlbum.value + if (album != null) { + _currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList) + logD("Updated genre to ${currentAlbum.value}") + } + + val artist = currentArtist.value + if (artist != null) { + _currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList) + logD("Updated genre to ${currentArtist.value}") + } + + val genre = currentGenre.value + if (genre != null) { + _currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList) + logD("Updated genre to ${currentGenre.value}") + } + } + + /** + * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading + * process will begin and the newly-loaded [DetailSong] will be set to [currentSong]. + * @param uid The UID of the [Song] to load. Must be valid. + */ fun setSongUid(uid: Music.UID) { - if (_currentSong.value?.run { song.uid } == uid) return - val library = unlikelyToBeNull(musicStore.library) - val song = requireNotNull(library.find(uid)) { "Invalid song id provided" } - generateDetailSong(song) - } - - fun clearSong() { - _currentSong.value = null + if (_currentSong.value?.run { song.uid } == uid) { + // Nothing to do. + return + } + + loadDetailSong(requireMusic(uid)) } + /** + * Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum] + * and [albumList] will be updated to align with the new [Album]. + * @param uid The UID of the [Album] to update to. Must be valid. + */ fun setAlbumUid(uid: Music.UID) { - if (_currentAlbum.value?.uid == uid) return - val library = unlikelyToBeNull(musicStore.library) - val album = requireNotNull(library.find(uid)) { "Invalid album id provided " } - _currentAlbum.value = album - refreshAlbumData(album) + if (_currentAlbum.value?.uid == uid) { + // Nothing to do. + return + } + + _currentAlbum.value = requireMusic(uid).also { refreshAlbumList(it) } } + /** + * Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist] + * and [artistList] will be updated to align with the new [Artist]. + * @param uid The UID of the [Album] to update to. Must be valid. + */ fun setArtistUid(uid: Music.UID) { - if (_currentArtist.value?.uid == uid) return - val library = unlikelyToBeNull(musicStore.library) - val artist = requireNotNull(library.find(uid)) { "Invalid artist id provided" } - _currentArtist.value = artist - refreshArtistData(artist) + if (_currentArtist.value?.uid == uid) { + // Nothing to do. + return + } + + _currentArtist.value = requireMusic(uid).also { refreshArtistList(it) } } + /** + * Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre] + * and [genreList] will be updated to align with the new album. + * @param uid The UID of the [Album] to update to. Must be valid. + */ fun setGenreUid(uid: Music.UID) { - if (_currentGenre.value?.uid == uid) return - val library = unlikelyToBeNull(musicStore.library) - val genre = requireNotNull(library.find(uid)) { "Invalid genre id provided" } - _currentGenre.value = genre - refreshGenreData(genre) + if (_currentGenre.value?.uid == uid) { + // Nothing to do. + return + } + + _currentGenre.value = requireMusic(uid).also { refreshGenreList(it) } } - private fun generateDetailSong(song: Song) { + /** + * A wrapper around [MusicStore.Library.find] that asserts that the returned data should + * be valid. + * @param T The type of music that should be found + * @param uid The [Music.UID] of the [T] to find + * @return A [T] with the given [Music.UID] + * @throws IllegalStateException If nothing can be found + */ + private fun requireMusic(uid: Music.UID): T = + requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" } + + /** + * Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file. + * @param song The song to load. + */ + private fun loadDetailSong(song: Song) { + // Clear any previous job in order to avoid stale data from appearing in the UI. currentSongJob?.cancel() _currentSong.value = DetailSong(song, null) currentSongJob = viewModelScope.launch(Dispatchers.IO) { - val info = generateDetailSongInfo(song) + val info = loadSongProperties(song) yield() _currentSong.value = DetailSong(song, info) } } - private fun generateDetailSongInfo(song: Song): SongInfo { + /** + * Load a new set of [DetailSong.Properties] based on the given [Song]'s file using + * [MediaExtractor]. + * @param song The song to load the properties from. + * @return A [DetailSong.Properties] containing the properties that could be + * extracted from the file. + */ + private fun loadSongProperties(song: Song): DetailSong.Properties { + // While we would use ExoPlayer to extract this information, it doesn't support + // common data like bit rate in progressive data sources due to there being no + // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. val extractor = MediaExtractor() try { extractor.setDataSource(application, song.uri, emptyMap()) } catch (e: Exception) { + // Can feasibly fail with invalid file formats. Note that this isn't considered + // an error condition in the UI, as there is still plenty of other song information + // that we can show. logW("Unable to extract song attributes.") logW(e.stackTraceToString()) - return SongInfo(null, null, song.mimeType) + return DetailSong.Properties(null, null, song.mimeType) } + // Get the first track from the extractor (This is basically always the only + // track we need to analyze). val format = extractor.getTrackFormat(0) + // Accessing fields can throw an exception if the fields are not present, and + // the new method for using default values is not available on lower API levels. + // So, we are forced to handle the exception and map it to a saner null value. val bitrate = try { - format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 // bps -> kbps - } catch (e: Exception) { + // Convert bytes-per-second to kilobytes-per-second. + format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 + } catch (e: NullPointerException) { + logD("Unable to extract bit rate field") null } val sampleRate = try { format.getInteger(MediaFormat.KEY_SAMPLE_RATE) - } catch (e: Exception) { + } catch (e: NullPointerException) { + logE("Unable to extract sample rate field") null } @@ -198,50 +351,63 @@ class DetailViewModel(application: Application) : // ExoPlayer was already able to populate the format. song.mimeType } else { + // ExoPlayer couldn't populate the format somehow, populate it here. val formatMimeType = try { format.getString(MediaFormat.KEY_MIME) - } catch (e: Exception) { + } catch (e: NullPointerException) { + logE("Unable to extract mime type field") null } MimeType(song.mimeType.fromExtension, formatMimeType) } - return SongInfo(bitrate, sampleRate, resolvedMimeType) + return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType) } - private fun refreshAlbumData(album: Album) { + /** + * Refresh [albumList] to reflect the given [Album] and any [Sort] changes. + * @param album The [Album] to create the list from. + */ + private fun refreshAlbumList(album: Album) { logD("Refreshing album data") val data = mutableListOf(album) data.add(SortHeader(R.string.lbl_songs)) - // To create a good user experience regarding disc numbers, we intersperse - // items that show the disc number throughout the album's songs. In the case - // that the album does not have distinct disc numbers, we omit such a header. + // To create a good user experience regarding disc numbers, we group the album's + // songs up by disc and then delimit the groups by a disc header. val songs = albumSort.songs(album.songs) + // Songs without disc tags become part of Disc 1. val byDisc = songs.groupBy { it.disc ?: 1 } if (byDisc.size > 1) { + logD("Album has more than one disc, interspersing headers") for (entry in byDisc.entries) { - val disc = entry.key - val discSongs = entry.value - data.add(DiscHeader(disc)) - data.addAll(discSongs) + data.add(DiscHeader(entry.key)) + data.addAll(entry.value) } } else { + // Album only has one disc, don't add any redundant headers data.addAll(songs) } _albumData.value = data } - private fun refreshArtistData(artist: Artist) { + /** + * Refresh [artistList] to reflect the given [Artist] and any [Sort] changes. + * @param artist The [Artist] to create the list from. + */ + private fun refreshArtistList(artist: Artist) { logD("Refreshing artist data") val data = mutableListOf(artist) val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums) val byReleaseGroup = albums.groupBy { + // Remap the complicated ReleaseType data structure into an easier + // "ReleaseTypeGrouping" enum that will automatically group and sort + // the artist's albums. when (it.releaseType.refinement) { ReleaseType.Refinement.LIVE -> ReleaseTypeGrouping.LIVE ReleaseType.Refinement.REMIX -> ReleaseTypeGrouping.REMIXES @@ -258,13 +424,16 @@ class DetailViewModel(application: Application) : } } + logD("Release groups for this artist: ${byReleaseGroup.keys}") + for (entry in byReleaseGroup.entries.sortedBy { it.key }) { - data.add(Header(entry.key.string)) + data.add(Header(entry.key.headerTitleRes)) data.addAll(entry.value) } // Artists may not be linked to any songs, only include a header entry if we have any. if (artist.songs.isNotEmpty()) { + logD("Songs present in this artist, adding header") data.add(SortHeader(R.string.lbl_songs)) data.addAll(artistSort.songs(artist.songs)) } @@ -272,77 +441,18 @@ class DetailViewModel(application: Application) : _artistData.value = data.toList() } - private fun refreshGenreData(genre: Genre) { + /** + * Refresh [genreList] to reflect the given [Genre] and any [Sort] changes. + * @param genre The [Genre] to create the list from. + */ + private fun refreshGenreList(genre: Genre) { logD("Refreshing genre data") val data = mutableListOf(genre) + // Genre is guaranteed to always have artists and songs. data.add(Header(R.string.lbl_artists)) data.addAll(genre.artists) data.add(SortHeader(R.string.lbl_songs)) data.addAll(genreSort.songs(genre.songs)) _genreData.value = data } - - // --- CALLBACKS --- - - override fun onLibraryChanged(library: MusicStore.Library?) { - if (library != null) { - val song = currentSong.value - if (song != null) { - logD("Song changed, refreshing data") - val newSong = library.sanitize(song.song) - if (newSong != null) { - generateDetailSong(newSong) - } else { - _currentSong.value = null - } - } - - val album = currentAlbum.value - if (album != null) { - logD("Album changed, refreshing data") - val newAlbum = library.sanitize(album).also { _currentAlbum.value = it } - if (newAlbum != null) { - refreshAlbumData(newAlbum) - } - } - - val artist = currentArtist.value - if (artist != null) { - logD("Artist changed, refreshing data") - val newArtist = library.sanitize(artist).also { _currentArtist.value = it } - if (newArtist != null) { - refreshArtistData(newArtist) - } - } - - val genre = currentGenre.value - if (genre != null) { - logD("Genre changed, refreshing data") - val newGenre = library.sanitize(genre).also { _currentGenre.value = it } - if (newGenre != null) { - refreshGenreData(newGenre) - } - } - } - } - - override fun onCleared() { - musicStore.removeCallback(this) - } - - private enum class ReleaseTypeGrouping(@StringRes val string: Int) { - ALBUMS(R.string.lbl_albums), - EPS(R.string.lbl_eps), - SINGLES(R.string.lbl_singles), - COMPILATIONS(R.string.lbl_compilations), - SOUNDTRACKS(R.string.lbl_soundtracks), - MIXES(R.string.lbl_mixes), - MIXTAPES(R.string.lbl_mixtapes), - LIVE(R.string.lbl_live_group), - REMIXES(R.string.lbl_remix_group), - } } - -data class SortHeader(@StringRes val string: Int) : Item - -data class DiscHeader(val disc: Int) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 321a78092..863af252d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -42,30 +42,21 @@ import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A fragment that shows information for a particular [Genre]. - * @author OxygenCobalt + * A [ListFragment] that shows information for a particular [Genre]. + * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailFragment : ListFragment() { +class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() - + // Information about what genre to display is initially within the navigation arguments + // as a UID, as that is the only safe way to parcel an genre. private val args: GenreDetailFragmentArgs by navArgs() - private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } - private val detailAdapter = - GenreDetailAdapter( - DetailAdapter.Callback( - ::handleClick, - ::handleOpenItemMenu, - ::handleSelect, - ::handlePlay, - ::handleShuffle, - ::handleOpenSortMenu)) + GenreDetailAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -77,47 +68,57 @@ class GenreDetailFragment : ListFragment() { override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + override fun getSelectionToolbar(binding: FragmentDetailBinding) = + binding.detailSelectionToolbar + override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { - setupSelectionToolbar(binding.detailSelectionToolbar) + super.onBindingCreated(binding, savedInstanceState) binding.detailToolbar.apply { inflateMenu(R.menu.menu_genre_artist_detail) setNavigationOnClickListener { findNavController().navigateUp() } - setOnMenuItemClickListener { - handleDetailMenuItem(it) - true - } + setOnMenuItemClickListener(this@GenreDetailFragment) } binding.detailRecycler.adapter = detailAdapter // --- VIEWMODEL SETUP --- + // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenreUid(args.genreUid) - collectImmediately(detailModel.currentGenre, ::handleItemChange) - collectImmediately(detailModel.genreData, detailAdapter::submitList) + collectImmediately(detailModel.currentGenre, ::updateItem) + collectImmediately(detailModel.genreList, detailAdapter::submitList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) - collectImmediately(selectionModel.selected, ::handleSelection) + collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentDetailBinding) { super.onDestroyBinding(binding) + binding.detailToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null } - private fun handleDetailMenuItem(item: MenuItem) { - when (item.itemId) { + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) + return when (item.itemId) { R.id.action_play_next -> { - playbackModel.playNext(unlikelyToBeNull(detailModel.currentGenre.value)) + playbackModel.playNext(currentGenre) requireContext().showToast(R.string.lng_queue_added) + true } R.id.action_queue_add -> { - playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentGenre.value)) + playbackModel.addToQueue(currentGenre) requireContext().showToast(R.string.lng_queue_added) + true } + else -> false } } @@ -125,20 +126,21 @@ class GenreDetailFragment : ListFragment() { when (music) { is Artist -> navModel.exploreNavigateTo(music) is Song -> - when (settings.detailPlaybackMode) { + when (val mode = Settings(requireContext()).detailPlaybackMode) { + // Only way to play from the genre is through "Play from selected item". null -> playbackModel.playFromGenre( music, unlikelyToBeNull(detailModel.currentGenre.value)) MusicMode.SONGS -> playbackModel.playFromAll(music) MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - else -> error("Unexpected playback mode: ${settings.detailPlaybackMode}") + else -> error("Unexpected playback mode: $mode") } else -> error("Unexpected datatype: ${music::class.simpleName}") } } - private fun handleOpenItemMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { when (item) { is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) @@ -146,15 +148,15 @@ class GenreDetailFragment : ListFragment() { } } - private fun handlePlay() { + override fun onPlay() { playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value)) } - private fun handleShuffle() { + override fun onShuffle() { playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value)) } - private fun handleOpenSortMenu(anchor: View) { + override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_genre_sort) { val sort = detailModel.genreSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true @@ -172,8 +174,13 @@ class GenreDetailFragment : ListFragment() { } } - private fun handleItemChange(genre: Genre?) { + /** + * Update the currently displayed [Genre] + * @param genre The new [Genre] to display. Null if there is no longer one. + */ + private fun updateItem(genre: Genre?) { if (genre == null) { + // Genre we were showing no longer exists. findNavController().navigateUp() return } @@ -181,6 +188,12 @@ class GenreDetailFragment : ListFragment() { requireBinding().detailToolbar.title = genre.resolveName(requireContext()) } + /** + * Update the current playback state in the context of the currently displayed [Genre]. + * @param song The current [Song] playing. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { var item: Item? = null @@ -195,7 +208,10 @@ class GenreDetailFragment : ListFragment() { detailAdapter.setPlayingItem(item, isPlaying) } - + /** + * Handle a navigation event. + * @param item The [Music] to navigate to, null if there is no item. + */ private fun handleNavigation(item: Music?) { when (item) { is Song -> { @@ -220,7 +236,11 @@ class GenreDetailFragment : ListFragment() { } } - private fun handleSelection(selected: List) { + /** + * Update the current item selection. + * @param selected The list of selected items. + */ + private fun updateSelection(selected: List) { detailAdapter.setSelectedItems(selected) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index 3a87850d1..692d7428e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -25,11 +25,12 @@ import com.google.android.material.textfield.TextInputEditText import org.oxycblt.auxio.R /** - * A [TextInputEditText] that deliberately restricts all input except for selection. Yes, this is a - * blatant abuse of Material Design Guidelines, but I also don't want to figure out how to plain - * text selectable. + * A [TextInputEditText] that deliberately restricts all input except for selection. This will + * work just like a normal block of selectable/copyable text, but with nicer aesthetics. * - * @author OxygenCobalt + * Adapted from Material Files: https://github.com/zhanghai/MaterialFiles + * + * @author Alexander Capehart (OxygenCobalt) */ class ReadOnlyTextInput @JvmOverloads @@ -40,15 +41,19 @@ constructor( ) : TextInputEditText(context, attrs, defStyleAttr) { init { + // Enable selection, but still disable focus (i.e Keyboard opening) setTextIsSelectable(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { focusable = View.FOCUSABLE_AUTO } } + // Make text immutable override fun getFreezesText() = false + // Prevent editing by default override fun getDefaultEditable() = false + // Remove the movement method that allows cursor scrolling override fun getDefaultMovementMethod() = null } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index 1836df8f3..b07cee3ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -32,12 +32,13 @@ import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately /** - * A dialog displayed when "View properties" is selected on a song, showing more information about - * the properties of the audio file itself. - * @author OxygenCobalt + * A [ViewBindingDialogFragment] that shows information about a Song. + * @author Alexander Capehart (OxygenCobalt) */ class SongDetailDialog : ViewBindingDialogFragment() { private val detailModel: DetailViewModel by androidActivityViewModels() + // Information about what song to display is initially within the navigation arguments + // as a UID, as that is the only safe way to parcel an song. private val args: SongDetailDialogArgs by navArgs() override fun onCreateBinding(inflater: LayoutInflater) = @@ -50,49 +51,60 @@ class SongDetailDialog : ViewBindingDialogFragment() { override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) + // DetailViewModel handles most initialization from the navigation argument. detailModel.setSongUid(args.songUid) collectImmediately(detailModel.currentSong, ::updateSong) } - override fun onDestroy() { - super.onDestroy() - detailModel.clearSong() - } - - private fun updateSong(song: DetailViewModel.DetailSong?) { + /** + * Update the currently displayed song. + * @param song The [DetailViewModel.DetailSong] to display. Null if there is no longer one. + */ + private fun updateSong(song: DetailSong?) { val binding = requireBinding() - if (song != null) { - if (song.info != null) { - binding.detailLoading.isInvisible = true - binding.detailContainer.isInvisible = false - - val context = requireContext() - binding.detailFileName.setText(song.song.path.name) - binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context)) - binding.detailFormat.setText(song.info.resolvedMimeType.resolveName(context)) - binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size)) - binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true)) - - if (song.info.bitrateKbps != null) { - binding.detailBitrate.setText( - getString(R.string.fmt_bitrate, song.info.bitrateKbps)) - } else { - binding.detailBitrate.setText(R.string.def_bitrate) - } - - if (song.info.sampleRate != null) { - binding.detailSampleRate.setText( - getString(R.string.fmt_sample_rate, song.info.sampleRate)) - } else { - binding.detailSampleRate.setText(R.string.def_sample_rate) - } - } else { - binding.detailLoading.isInvisible = false - binding.detailContainer.isInvisible = true - } - } else { + if (song == null) { + // Song we were showing no longer exists. findNavController().navigateUp() + return + } + + if (song.properties != null) { + // Finished loading Song properties, populate and show the list of Song information. + val context = requireContext() + // File name + binding.detailFileName.setText(song.song.path.name) + // Relative (Parent) directory + binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context)) + // Format + binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context)) + // Size + binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size)) + // Duration + binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true)) + + // Bit rate (if present) + if (song.properties.bitrateKbps != null) { + binding.detailBitrate.setText( + getString(R.string.fmt_bitrate, song.properties.bitrateKbps)) + } else { + binding.detailBitrate.setText(R.string.def_bitrate) + } + + // Sample rate (if present) + if (song.properties.sampleRateHz != null) { + binding.detailSampleRate.setText( + getString(R.string.fmt_sample_rate, song.properties.sampleRateHz)) + } else { + binding.detailSampleRate.setText(R.string.def_sample_rate) + } + + binding.detailLoading.isInvisible = true + binding.detailContainer.isInvisible = false + } else { + // Loading is still on-going, don't show anything yet. + binding.detailLoading.isInvisible = false + binding.detailContainer.isInvisible = true } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index c5674a45d..abdf0aa98 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -27,6 +27,7 @@ import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.detail.DiscHeader +import org.oxycblt.auxio.list.ExtendedListListener import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SimpleItemCallback @@ -38,14 +39,26 @@ import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater /** - * An adapter for displaying [Album] information and it's children. - * @author OxygenCobalt + * An [DetailAdapter] implementing the header and sub-items for the [Album] detail view. + * @param listener A [Listener] for list interactions. + * @author Alexander Capehart (OxygenCobalt) */ -class AlbumDetailAdapter(private val callback: Callback) : - DetailAdapter(callback, DIFFER) { +class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { + /** + * An extension to [DetailAdapter.Listener] that enables interactions specific to the album + * detail view. + */ + interface Listener : DetailAdapter.Listener { + /** + * Called when the artist name in the [Album] header was clicked, requesting navigation to + * it's parent artist. + */ + fun onNavigateToParentArtist() + } override fun getItemViewType(position: Int) = when (differ.currentList[position]) { + // Support the Album header, sub-headers for each disc, and special album songs. is Album -> AlbumDetailViewHolder.VIEW_TYPE is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE @@ -60,92 +73,99 @@ class AlbumDetailAdapter(private val callback: Callback) : else -> super.onCreateViewHolder(parent, viewType) } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - when (val item = differ.currentList[position]) { - is Album -> (holder as AlbumDetailViewHolder).bind(item, callback) - is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) - is Song -> (holder as AlbumSongViewHolder).bind(item, callback) - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + when (val item = differ.currentList[position]) { + is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) + is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) + is Song -> (holder as AlbumSongViewHolder).bind(item, listener) } } override fun isItemFullWidth(position: Int): Boolean { + // The album and disc headers should be full-width in all configurations. val item = differ.currentList[position] return super.isItemFullWidth(position) || item is Album || item is DiscHeader } companion object { - private val DIFFER = + /** A comparator that can be used with DiffUtil. */ + private val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Album && newItem is Album -> - AlbumDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is DiscHeader && newItem is DiscHeader -> - DiscHeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - AlbumSongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) - else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) + AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + + // Fall back to DetailAdapter's differ to handle other headers. + else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) } } } } - - class Callback( - onClick: (Item) -> Unit, - onOpenItemMenu: (Item, View) -> Unit, - onSelect: (Item) -> Unit, - onPlay: () -> Unit, - onShuffle: () -> Unit, - onOpenSortMenu: (View) -> Unit, - val onNavigateToArtist: () -> Unit - ) : - DetailAdapter.Callback( - onClick, onOpenItemMenu, onSelect, onPlay, onShuffle, onOpenSortMenu) } +/** + * A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: Album, callback: AlbumDetailAdapter.Callback) { - binding.detailCover.bind(item) - binding.detailType.text = binding.context.getString(item.releaseType.stringRes) + /** + * Bind new data to this instance. + * @param album The new [Album] to bind. + * @param listener A [AlbumDetailAdapter.Listener] to bind interactions to. + */ + fun bind(album: Album, listener: AlbumDetailAdapter.Listener) { + binding.detailCover.bind(album) - binding.detailName.text = item.resolveName(binding.context) + // The type text depends on the release type (Album, EP, Single, etc.) + binding.detailType.text = binding.context.getString(album.releaseType.stringRes) + binding.detailName.text = album.resolveName(binding.context) + + // Artist name maps to the subhead text binding.detailSubhead.apply { - text = item.resolveArtistContents(context) - setOnClickListener { callback.onNavigateToArtist() } + text = album.resolveArtistContents(context) + + // Add a QoL behavior where navigation to the artist will occur if the artist + // name is pressed. + setOnClickListener { listener.onNavigateToParentArtist() } } + // Date, song count, and duration map to the info text binding.detailInfo.apply { - val date = item.date?.resolveDate(context) ?: context.getString(R.string.def_date) - - val songCount = context.getPlural(R.plurals.fmt_song_count, item.songs.size) - - val duration = item.durationMs.formatDurationMs(true) - + // Fall back to a friendlier "No date" text if the album doesn't have date information + val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date) + val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size) + val duration = album.durationMs.formatDurationMs(true) text = context.getString(R.string.fmt_three, date, songCount, duration) } - binding.detailPlayButton.setOnClickListener { callback.onPlay() } - binding.detailShuffleButton.setOnClickListener { callback.onShuffle() } + binding.detailPlayButton.setOnClickListener { listener.onPlay() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_DETAIL + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && @@ -158,20 +178,35 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite } } -class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : +/** + * A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use + * [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ +private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(item: DiscHeader) { - binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, item.disc) + /** + * Bind new data to this instance. + * @param discHeader The new [DiscHeader] to bind. + */ + fun bind(discHeader: DiscHeader) { + binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.disc) } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_HEADER + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = oldItem.disc == newItem.disc @@ -179,35 +214,41 @@ class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : } } +/** + * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - fun bind(item: Song, callback: AlbumDetailAdapter.Callback) { - // Hide the track number view if the song does not have a track. - if (item.track != null) { - binding.songTrack.apply { - text = context.getString(R.string.fmt_number, item.track) + /** + * Bind new data to this instance. + * @param song The new [Song] to bind. + * @param listener A [ExtendedListListener] to bind interactions to. + */ + fun bind(song: Song, listener: ExtendedListListener) { + listener.bind(song, binding.root, binding.songMenu) + + binding.songTrack.apply { + if (song.track != null) { + // Instead of an album cover, we show the track number, as the song list + // within the album detail view would have homogeneous album covers otherwise. + text = context.getString(R.string.fmt_number, song.track) isInvisible = false - contentDescription = context.getString(R.string.desc_track_number, item.track) - } - } else { - binding.songTrack.apply { + contentDescription = context.getString(R.string.desc_track_number, song.track) + } else { + // No track, do not show a number, instead showing a generic icon. text = "" isInvisible = true contentDescription = context.getString(R.string.def_track) } } - binding.songName.text = item.resolveName(binding.context) - binding.songDuration.text = item.durationMs.formatDurationMs(false) + binding.songName.text = song.resolveName(binding.context) - binding.songMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + // Use duration instead of album or artist for each song, as this text would + // be homogenous otherwise. + binding.songDuration.text = song.durationMs.formatDurationMs(false) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -220,12 +261,19 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_SONG + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 9f647764d..285e2bc8e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -26,10 +26,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding +import org.oxycblt.auxio.list.ExtendedListListener import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.ItemMenuCallback -import org.oxycblt.auxio.list.ItemSelectCallback -import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SimpleItemCallback import org.oxycblt.auxio.music.Album @@ -40,14 +38,14 @@ import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater /** - * An adapter for displaying [Artist] information and it's children. Unlike the other adapters, this - * one actually contains both album information and song information. - * @author OxygenCobalt + * A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view. + * @param listener A [DetailAdapter.Listener] for list interactions. + * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailAdapter(private val callback: Callback) : DetailAdapter(callback, DIFFER) { - +class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { + // Support an artist header, and special artist albums/songs. is Artist -> ArtistDetailViewHolder.VIEW_TYPE is Album -> ArtistAlbumViewHolder.VIEW_TYPE is Song -> ArtistSongViewHolder.VIEW_TYPE @@ -62,88 +60,107 @@ class ArtistDetailAdapter(private val callback: Callback) : DetailAdapter(callba else -> super.onCreateViewHolder(parent, viewType) } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - when (val item = differ.currentList[position]) { - is Artist -> (holder as ArtistDetailViewHolder).bind(item, callback) - is Album -> (holder as ArtistAlbumViewHolder).bind(item, callback) - is Song -> (holder as ArtistSongViewHolder).bind(item, callback) - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + // Re-binding an item with new data and not just a changed selection/playing state. + when (val item = differ.currentList[position]) { + is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener) + is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener) + is Song -> (holder as ArtistSongViewHolder).bind(item, listener) } } override fun isItemFullWidth(position: Int): Boolean { + // Artist headers should be full-width in all configurations. val item = differ.currentList[position] return super.isItemFullWidth(position) || item is Artist } companion object { - private val DIFFER = + /** A comparator that can be used with DiffUtil. */ + private val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Artist && newItem is Artist -> - ArtistDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame( + oldItem, newItem) oldItem is Album && newItem is Album -> - ArtistAlbumViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - ArtistSongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) - else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) + ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) } } } } } +/** + * A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: Artist, callback: DetailAdapter.Callback) { - binding.detailCover.bind(item) + /** + * Bind new data to this instance. + * @param artist The new [Artist] to bind. + * @param listener A [DetailAdapter.Listener] to bind interactions to. + */ + fun bind(artist: Artist, listener: DetailAdapter.Listener) { + binding.detailCover.bind(artist) binding.detailType.text = binding.context.getString(R.string.lbl_artist) - binding.detailName.text = item.resolveName(binding.context) + binding.detailName.text = artist.resolveName(binding.context) - if (item.songs.isNotEmpty()) { + if (artist.songs.isNotEmpty()) { + // Information about the artist's genre(s) map to the sub-head text binding.detailSubhead.apply { isVisible = true - text = item.resolveGenreContents(binding.context) + text = artist.resolveGenreContents(binding.context) } + // Song and album counts map to the info binding.detailInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), - binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)) + binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)) + // In the case that this header used to he configured to have no songs, + // we want to reset the visibility of all information that was hidden. binding.detailPlayButton.isVisible = true binding.detailShuffleButton.isVisible = true } else { - // The artist does not have any songs, so playback, genre info, and song counts - // make no sense. + // The artist does not have any songs, so hide functionality that makes no sense. + // ex. Play and Shuffle, Song Counts, and Genre Information. + // Artists are always guaranteed to have albums however, so continue to show those. binding.detailSubhead.isVisible = false binding.detailInfo.text = - binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size) + binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size) binding.detailPlayButton.isVisible = false binding.detailShuffleButton.isVisible = false } - binding.detailPlayButton.setOnClickListener { callback.onPlay() } - binding.detailShuffleButton.setOnClickListener { callback.onShuffle() } + binding.detailPlayButton.setOnClickListener { listener.onPlay() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_DETAIL + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.rawName == newItem.rawName && @@ -154,22 +171,25 @@ private class ArtistDetailViewHolder private constructor(private val binding: It } } +/** + * A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - fun bind(item: Album, callback: ItemSelectCallback) { - binding.parentImage.bind(item) - binding.parentName.text = item.resolveName(binding.context) + /** + * Bind new data to this instance. + * @param album The new [Album] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(album: Album, listener: ExtendedListListener) { + listener.bind(album, binding.root, binding.parentMenu) + binding.parentImage.bind(album) + binding.parentName.text = album.resolveName(binding.context) binding.parentInfo.text = - item.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date) - binding.parentMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.setOnClickListener { callback.onClick(item) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + // Fall back to a friendlier "No date" text if the album doesn't have date information + album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -182,12 +202,19 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.date == newItem.date @@ -195,21 +222,23 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite } } +/** + * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - fun bind(item: Song, callback: ItemSelectCallback) { - binding.songAlbumCover.bind(item) - binding.songName.text = item.resolveName(binding.context) - binding.songInfo.text = item.album.resolveName(binding.context) - binding.songMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.setOnClickListener { callback.onClick(item) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + /** + * Bind new data to this instance. + * @param song The new [Song] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(song: Song, listener: ExtendedListListener) { + listener.bind(song, binding.root, binding.songMenu) + binding.songAlbumCover.bind(song) + binding.songName.text = song.resolveName(binding.context) + binding.songInfo.text = song.album.resolveName(binding.context) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -222,12 +251,19 @@ private class ArtistSongViewHolder private constructor(private val binding: Item } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_SONG + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 4d50bfd52..54688b972 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -26,21 +26,54 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.detail.SortHeader +import org.oxycblt.auxio.list.ExtendedListListener import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.ItemSelectCallback import org.oxycblt.auxio.list.recycler.* import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater +/** + * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. + * @param callback A [Listener] for list interactions. + * @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the + * internal list. + * @author Alexander Capehart (OxygenCobalt) + */ abstract class DetailAdapter( - private val callback: Callback, - diffCallback: DiffUtil.ItemCallback + private val callback: Listener, + itemCallback: DiffUtil.ItemCallback ) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - @Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size + /** An extended [ExtendedListListener] for [DetailAdapter] implementations. */ + interface Listener : ExtendedListListener { + // TODO: Split off into sub-listeners if a collapsing toolbar is implemented. + /** + * Called when the play button in a detail header is pressed, requesting that the current + * item should be played. + */ + fun onPlay() + + /** + * Called when the shuffle button in a detail header is pressed, requesting that the current + * item should be shuffled + */ + fun onShuffle() + + /** + * Called when the button in a [SortHeader] item is pressed, requesting that the sort menu + * should be opened. + */ + fun onOpenSortMenu(anchor: View) + } + + // Safe to leak this since the callback will not fire during initialization + @Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback) + + override fun getItemCount() = differ.currentList.size override fun getItemViewType(position: Int) = when (differ.currentList[position]) { + // Implement support for headers and sort headers is Header -> HeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) @@ -53,85 +86,87 @@ abstract class DetailAdapter( else -> error("Invalid item type $viewType") } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) = - throw NotImplementedError() - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - val item = differ.currentList[position] - - if (payloads.isEmpty()) { - when (item) { - is Header -> (holder as HeaderViewHolder).bind(item) - is SortHeader -> (holder as SortHeaderViewHolder).bind(item, callback) - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = differ.currentList[position]) { + is Header -> (holder as HeaderViewHolder).bind(item) + is SortHeader -> (holder as SortHeaderViewHolder).bind(item, callback) } - - super.onBindViewHolder(holder, position, payloads) } override fun isItemFullWidth(position: Int): Boolean { + // Headers should be full-width in all configurations. val item = differ.currentList[position] return item is Header || item is SortHeader } - @Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, diffCallback) - override val currentList: List get() = differ.currentList - fun submitList(list: List) { - differ.submitList(list) + /** + * Asynchronously update the list with new items. Assumes that the list only contains data + * supported by the concrete [DetailAdapter] implementation. + * @param newList The new [Item]s for the adapter to display. + */ + fun submitList(newList: List) { + differ.submitList(newList) } companion object { - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is SortHeader && newItem is SortHeader -> - SortHeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false } } } } - - open class Callback( - onClick: (Item) -> Unit, - onOpenItemMenu: (Item, View) -> Unit, - onSelect: (Item) -> Unit, - val onPlay: () -> Unit, - val onShuffle: () -> Unit, - val onOpenSortMenu: (View) -> Unit - ) : ItemSelectCallback(onClick, onOpenItemMenu, onSelect) } -class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : +/** + * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a + * button opening a menu for sorting. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ +private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: SortHeader, callback: DetailAdapter.Callback) { - binding.headerTitle.text = binding.context.getString(item.string) + /** + * Bind new data to this instance. + * @param sortHeader The new [SortHeader] to bind. + * @param listener An [DetailAdapter.Listener] to bind interactions to. + */ + fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) { + binding.headerTitle.text = binding.context.getString(sortHeader.titleRes) binding.headerButton.apply { + // Add a Tooltip based on the content description so that the purpose of this + // button can be clear. TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener(callback.onOpenSortMenu) + setOnClickListener(listener::onOpenSortMenu) } } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SORT_HEADER + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) = - oldItem.string == newItem.string + oldItem.titleRes == newItem.titleRes } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index 1e63f39f8..13211d150 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -36,12 +36,16 @@ import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater /** - * An adapter for displaying genre information and it's children. - * @author OxygenCobalt + * An [DetailAdapter] implementing the header and sub-items for the [Genre] detail view. + * @param listener A [DetailAdapter.Listener] for list interactions. + * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailAdapter(private val callback: Callback) : DetailAdapter(callback, DIFFER) { +class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (differ.currentList[position]) { + // Support the Genre header and generic Artist/Song items. There's nothing about + // a genre that will make the artists/songs homogeneous, so it doesn't matter what we + // use for their ViewHolders. is Genre -> GenreDetailViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE is Song -> SongViewHolder.VIEW_TYPE @@ -56,69 +60,80 @@ class GenreDetailAdapter(private val callback: Callback) : DetailAdapter(callbac else -> super.onCreateViewHolder(parent, viewType) } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - when (val item = differ.currentList[position]) { - is Genre -> (holder as GenreDetailViewHolder).bind(item, callback) - is Artist -> (holder as ArtistViewHolder).bind(item, callback) - is Song -> (holder as SongViewHolder).bind(item, callback) - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = differ.currentList[position]) { + is Genre -> (holder as GenreDetailViewHolder).bind(item, listener) + is Artist -> (holder as ArtistViewHolder).bind(item, listener) + is Song -> (holder as SongViewHolder).bind(item, listener) } } override fun isItemFullWidth(position: Int): Boolean { + // Genre headers should be full-width in all configurations val item = differ.currentList[position] return super.isItemFullWidth(position) || item is Genre } companion object { - val DIFFER = + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Genre && newItem is Genre -> - GenreDetailViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + GenreDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Artist && newItem is Artist -> - ArtistViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Song && newItem is Song -> - SongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) - else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem) + SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) } } } } } +/** + * A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [new] to + * create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: Genre, callback: DetailAdapter.Callback) { + /** + * Bind new data to this instance. + * @param genre The new [Song] to bind. + * @param listener A [DetailAdapter.Listener] to bind interactions to. + */ + fun bind(item: Genre, listener: DetailAdapter.Listener) { binding.detailCover.bind(item) binding.detailType.text = binding.context.getString(R.string.lbl_genre) binding.detailName.text = item.resolveName(binding.context) + // Nothing about a genre is applicable to the sub-head text. binding.detailSubhead.isVisible = false + // The song count of the genre maps to the info text. binding.detailInfo.text = binding.context.getString( R.string.fmt_two, binding.context.getPlural(R.plurals.fmt_artist_count, item.artists.size), binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)) - - binding.detailPlayButton.setOnClickListener { callback.onPlay() } - binding.detailShuffleButton.setOnClickListener { callback.onShuffle() } + binding.detailPlayButton.setOnClickListener { listener.onPlay() } + binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } } companion object { + /** A unique ID for this [RecyclerView.ViewHolder] type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE_DETAIL + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = oldItem.rawName == newItem.rawName && diff --git a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt index ab26b0967..87032bfe6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [FrameLayout] that automatically applies bottom insets. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class EdgeFrameLayout @JvmOverloads @@ -37,7 +37,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { - // Save a layout by simply moving the view bounds upwards + // Prevent excessive layouts by using translation instead of padding. translationY = -insets.systemBarInsetsCompat.bottom.toFloat() return insets } 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 50b5895c1..2122a36b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -27,7 +27,9 @@ import androidx.core.view.isVisible import androidx.core.view.iterator import androidx.core.view.updatePadding import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LifecycleOwner import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter @@ -45,8 +47,8 @@ import org.oxycblt.auxio.home.list.AlbumListFragment import org.oxycblt.auxio.home.list.ArtistListFragment import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.SongListFragment -import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.selection.SelectionToolbarOverlay +import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy +import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -57,16 +59,19 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.shared.MainNavigationAction +import org.oxycblt.auxio.shared.NavigationViewModel import org.oxycblt.auxio.util.* /** - * The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each - * respective item. - * @author OxygenCobalt + * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation + * to other views. + * @author Alexander Capehart (OxygenCobalt) */ -class HomeFragment : ListFragment() { +class HomeFragment : + SelectionFragment(), AppBarLayout.OnOffsetChangedListener { private val homeModel: HomeViewModel by androidActivityViewModels() private val musicModel: MusicViewModel by activityViewModels() + private val navModel: NavigationViewModel by activityViewModels() // lifecycleObject builds this in the creation step, so doing this is okay. private val storagePermissionLauncher: ActivityResultLauncher by lifecycleObject { @@ -95,15 +100,14 @@ class HomeFragment : ListFragment() { override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater) - override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { - binding.homeAppbar.addOnOffsetChangedListener { _, it -> handleAppBarAnimation(it) } - setupSelectionToolbar(binding.homeSelectionToolbar) - binding.homeToolbar.setOnMenuItemClickListener { - handleHomeMenuItem(it) - true - } + override fun getSelectionToolbar(binding: FragmentHomeBinding) = + binding.homeSelectionToolbar - setupTabs(binding) + override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.homeAppbar.addOnOffsetChangedListener(this) + binding.homeToolbar.setOnMenuItemClickListener(this) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources @@ -111,18 +115,16 @@ class HomeFragment : ListFragment() { requireContext().getColorCompat(R.color.sel_track).defaultColor binding.homePager.apply { - adapter = HomePagerAdapter() - + // Update HomeViewModel whenever the user swipes through the ViewPager. + // This would be implemented in HomeFragment itself, but OnPageChangeCallback + // is an object for some reason. registerOnPageChangeCallback( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { - homeModel.setCurrentTab(position) + homeModel.synchronizeTabPosition(position) } }) - TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel)) - .attach() - // ViewPager2 will nominally consume window insets, which will then break the window // insets applied to the indexing view before API 30. Fix this by overriding the // callback with a non-consuming listener. @@ -131,27 +133,29 @@ class HomeFragment : ListFragment() { // 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 + offscreenPageLimit = homeModel.currentTabModes.size // 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: + // 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 val recycler = VP_RECYCLER_FIELD.get(this@apply) val slop = RV_TOUCH_SLOP_FIELD.get(recycler) as Int RV_TOUCH_SLOP_FIELD.set(recycler, slop * 3) } + // Further initialization must be done in the function that also handles + // re-creating the ViewPager. + setupPager(binding) + binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } // --- VIEWMODEL SETUP --- - collect(homeModel.recreateTabs, ::handleRecreateTabs) - collectImmediately(homeModel.currentTab, ::updateCurrentTab) - collectImmediately(homeModel.songs, homeModel.isFastScrolling, ::updateFab) + collect(homeModel.shouldRecreate, ::handleRecreate) + collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) + collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexerState, ::updateIndexerState) @@ -168,19 +172,31 @@ class HomeFragment : ListFragment() { super.onSaveInstanceState(outState) } - private fun handleAppBarAnimation(verticalOffset: Int) { - val binding = requireBinding() - val range = binding.homeAppbar.totalScrollRange + override fun onDestroyBinding(binding: FragmentHomeBinding) { + super.onDestroyBinding(binding) + binding.homeAppbar.removeOnOffsetChangedListener(this) + binding.homeToolbar.setOnMenuItemClickListener(null) + } + override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { + val binding = requireBinding() + val range = appBarLayout.totalScrollRange + // Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap, + // the alpha transition is shifted such that the Toolbar becomes fully transparent + // when the AppBarLayout is only at half-collapsed. binding.homeSelectionToolbar.alpha = 1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2)) - binding.homeContent.updatePadding( bottom = binding.homeAppbar.totalScrollRange + verticalOffset) } - private fun handleHomeMenuItem(item: MenuItem) { + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + when (item.itemId) { + // Handle main actions (Search, Settings, About) R.id.action_search -> { logD("Navigating to search") initAxisTransitions(MaterialSharedAxis.Z) @@ -196,90 +212,127 @@ class HomeFragment : ListFragment() { navModel.mainNavigateTo( MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout())) } + + // Handle sort menu R.id.submenu_sorting -> { // Junk click event when opening the menu } R.id.option_sort_asc -> { item.isChecked = !item.isChecked - homeModel.updateCurrentSort( + homeModel.setSortForCurrentTab( homeModel - .getSortForTab(homeModel.currentTab.value) + .getSortForTab(homeModel.currentTabMode.value) .withAscending(item.isChecked)) } else -> { // Sorting option was selected, mark it as selected and update the mode item.isChecked = true - homeModel.updateCurrentSort( + homeModel.setSortForCurrentTab( homeModel - .getSortForTab(homeModel.currentTab.value) + .getSortForTab(homeModel.currentTabMode.value) .withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId)))) } } + + // Always handling it one way or another, so always return true + return true } - private fun updateCurrentTab(tab: MusicMode) { - // Make sure that we update the scrolling view and allowed menu items whenever - // the tab changes. - when (tab) { - MusicMode.SONGS -> { - updateSortMenu(tab) { id -> id != R.id.option_sort_count } + /** + * Set up the TabLayout and [ViewPager2] to reflect the current tab configuration. + * @param binding The [FragmentHomeBinding] to apply the tab configuration to. + */ + private fun setupPager(binding: FragmentHomeBinding) { + binding.homePager.adapter = HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner) + + val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams + if (homeModel.currentTabModes.size == 1) { + // A single tab makes the tab layout redundant, hide it and disable the collapsing + // behavior. + binding.homeTabs.isVisible = false + binding.homeAppbar.setExpanded(true, false) + toolbarParams.scrollFlags = 0 + } else { + binding.homeTabs.isVisible = true + toolbarParams.scrollFlags = + AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or + AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS + } + + // Set up the mapping between the ViewPager and TabLayout. + TabLayoutMediator(binding.homeTabs, binding.homePager, + AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes)).attach() + } + + /** + * Update the UI to reflect the current tab. + * @param tabMode The [MusicMode] of the currently shown tab. + */ + private fun updateCurrentTab(tabMode: MusicMode) { + // Update the sort options to align with those allowed by the tab + val isVisible: (Int) -> Boolean = when (tabMode) { + // Disallow sorting by count for songs + MusicMode.SONGS -> { id -> id != R.id.option_sort_count } + // Disallow sorting by album for albums + MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album } + // Only allow sorting by name, count, and duration for artists + MusicMode.ARTISTS -> { id -> + id == R.id.option_sort_asc || + id == R.id.option_sort_name || + id == R.id.option_sort_count || + id == R.id.option_sort_duration } - MusicMode.ALBUMS -> { - updateSortMenu(tab) { id -> id != R.id.option_sort_album } - } - MusicMode.ARTISTS -> { - updateSortMenu(tab) { id -> - id == R.id.option_sort_asc || - id == R.id.option_sort_name || - id == R.id.option_sort_count || - id == R.id.option_sort_duration - } - } - MusicMode.GENRES -> { - updateSortMenu(tab) { id -> - id == R.id.option_sort_asc || - id == R.id.option_sort_name || - id == R.id.option_sort_count || - id == R.id.option_sort_duration - } + // Only allow sorting by name, count, and duration for genres + MusicMode.GENRES -> { id -> + id == R.id.option_sort_asc || + id == R.id.option_sort_name || + id == R.id.option_sort_count || + id == R.id.option_sort_duration } } - requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tab) - } - - private fun updateSortMenu(mode: MusicMode, isVisible: (Int) -> Boolean) { val sortMenu = requireNotNull(sortItem.subMenu) - val toHighlight = homeModel.getSortForTab(mode) + val toHighlight = homeModel.getSortForTab(tabMode) for (option in sortMenu) { - if (option.itemId == toHighlight.mode.itemId) { - option.isChecked = true - } - - if (option.itemId == R.id.option_sort_asc) { - option.isChecked = toHighlight.isAscending - } + // Check the ascending option and corresponding sort option to align with + // the current sort of the tab. + option.isChecked = option.itemId == toHighlight.mode.itemId + || (option.itemId == R.id.option_sort_asc && toHighlight.isAscending) + // Disable options that are not allowed by the isVisible lambda option.isVisible = isVisible(option.itemId) } + + // Update the scrolling view in AppBarLayout to align with the current tab's + // scrolling state. This prevents the lift state from being confused as one + // goes between different tabs. + requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode) } - private fun handleRecreateTabs(recreate: Boolean) { - if (recreate) { - val binding = requireBinding() - - binding.homePager.apply { - currentItem = 0 - adapter = HomePagerAdapter() - } - - setupTabs(binding) - - homeModel.finishRecreateTabs() + /** + * Handle a request to recreate all home tabs. + * @param recreate Whether to recreate all tabs. + */ + private fun handleRecreate(recreate: Boolean) { + if (!recreate) { + // Nothing to do + return } + + val binding = requireBinding() + // Move back to position zero, as there must be a tab there. + binding.homePager.currentItem = 0 + // Make sure tabs are set up to also follow the new ViewPager configuration. + setupPager(binding) + homeModel.finishRecreate() } + /** + * Update the currently displayed [Indexer.State] + * @param state The new [Indexer.State] to show. Null if the state is currently + * indeterminate (Not loading or complete). + */ private fun updateIndexerState(state: Indexer.State?) { val binding = requireBinding() when (state) { @@ -292,21 +345,27 @@ class HomeFragment : ListFragment() { } } + /** + * Configure the UI to display the given [Indexer.Response]. + * @param binding The [FragmentHomeBinding] whose views should be updated. + * @param response The [Indexer.Response] to show. + */ private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) { if (response is Indexer.Response.Ok) { + logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE } else { + logD("Received non-ok response") val context = requireContext() - binding.homeIndexingContainer.visibility = View.VISIBLE - - logD("Received non-ok response $response") - + binding.homeIndexingProgress.visibility = View.INVISIBLE when (response) { is Indexer.Response.Err -> { - binding.homeIndexingProgress.visibility = View.INVISIBLE + logD("Updating UI to Response.Err state") binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) + + // Configure the indexing button to act as a rescan trigger. binding.homeIndexingAction.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) @@ -314,8 +373,10 @@ class HomeFragment : ListFragment() { } } is Indexer.Response.NoMusic -> { - binding.homeIndexingProgress.visibility = View.INVISIBLE + logD("Updating UI to Response.NoMusic state") binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) + + // Configure the indexing button to act as a rescan trigger. binding.homeIndexingAction.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) @@ -323,8 +384,10 @@ class HomeFragment : ListFragment() { } } is Indexer.Response.NoPerms -> { - binding.homeIndexingProgress.visibility = View.INVISIBLE + logD("Updating UI to Response.NoPerms state") binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) + + // Configure the indexing button to act as a permission launcher. binding.homeIndexingAction.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_grant) @@ -338,21 +401,27 @@ class HomeFragment : ListFragment() { } } + /** + * Configure the UI to display the given [Indexer.Indexing] state.. + * @param binding The [FragmentHomeBinding] whose views should be updated. + * @param indexing The [Indexer.Indexing] state to show. + */ private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) { + // Remove all content except for the progress indicator. binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE binding.homeIndexingAction.visibility = View.INVISIBLE - val context = requireContext() - when (indexing) { is Indexer.Indexing.Indeterminate -> { - binding.homeIndexingStatus.text = context.getString(R.string.lng_indexing) + // In a query/initialization state, show a generic loading status. + binding.homeIndexingStatus.text = getString(R.string.lng_indexing) binding.homeIndexingProgress.isIndeterminate = true } is Indexer.Indexing.Songs -> { + // Actively loading songs, show the current progress. binding.homeIndexingStatus.text = - context.getString(R.string.fmt_indexing, indexing.current, indexing.total) + getString(R.string.fmt_indexing, indexing.current, indexing.total) binding.homeIndexingProgress.apply { isIndeterminate = false max = indexing.total @@ -362,8 +431,16 @@ class HomeFragment : ListFragment() { } } + /** + * Update the FAB visibility to reflect the state of other home elements. + * @param songs The current list of songs in the home view. + * @param isFastScrolling Whether the user is currently fast-scrolling. + */ private fun updateFab(songs: List, isFastScrolling: Boolean) { val binding = requireBinding() + // If there are no songs, it's likely that the library has not been loaded, so + // displaying the shuffle FAB makes no sense. We also don't want the fast scroll + // popup to overlap with the FAB, so we hide the FAB when fast scrolling too. if (songs.isEmpty() || isFastScrolling) { binding.homeFab.hide() } else { @@ -371,6 +448,10 @@ class HomeFragment : ListFragment() { } } + /** + * Handle a navigation event. + * @param item The [Music] to navigate to, null if there is no item. + */ private fun handleNavigation(item: Music?) { val action = when (item) { @@ -385,6 +466,10 @@ class HomeFragment : ListFragment() { findNavController().navigate(action) } + /** + * Update the current item selection. + * @param selected The list of selected items. + */ private fun updateSelection(selected: List) { val binding = requireBinding() if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) && @@ -392,59 +477,62 @@ class HomeFragment : ListFragment() { logD("Significant selection occurred, expanding AppBar") // Significant enough change where we want to expand the RecyclerView binding.homeAppbar.expandWithRecycler( - binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTab.value))) + binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value))) } } - /** Returns the ID of a RecyclerView that the given [tab] contains */ - private fun getTabRecyclerId(tab: MusicMode) = - when (tab) { + /** + * Get the ID of the RecyclerView contained by a tab. + * @param tabMode The mode of the tab to get the ID from + * @return The ID of the RecyclerView contained by the given tab. + */ + private fun getTabRecyclerId(tabMode: MusicMode) = + when (tabMode) { MusicMode.SONGS -> R.id.home_song_recycler MusicMode.ALBUMS -> R.id.home_album_recycler MusicMode.ARTISTS -> R.id.home_artist_recycler MusicMode.GENRES -> R.id.home_genre_recycler } - private fun setupTabs(binding: FragmentHomeBinding) { - val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams - if (homeModel.tabs.size == 1) { - // A single tab makes the tab layout redundant, hide it and disable the collapsing - // behavior. - binding.homeTabs.isVisible = false - binding.homeAppbar.setExpanded(true, false) - toolbarParams.scrollFlags = 0 - } else { - binding.homeTabs.isVisible = true - toolbarParams.scrollFlags = - AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or - AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS - } - } - + /** + * Set up [MaterialSharedAxis] transitions. + * @param axis The axis that the transition should occur on, such as [MaterialSharedAxis.X] + */ private fun initAxisTransitions(axis: Int) { - // Sanity check + // Sanity check to avoid in-correct axis transitions check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) { "Not expecting Y axis transition" } + enterTransition = MaterialSharedAxis(axis, true) returnTransition = MaterialSharedAxis(axis, false) exitTransition = MaterialSharedAxis(axis, true) reenterTransition = MaterialSharedAxis(axis, false) } - private inner class HomePagerAdapter : - FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { + /** + * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance. + * @param tabs The current tab configuration. This should define the fragments created. + * @param fragmentManager The [FragmentManager] required by [FragmentStateAdapter]. + * @param lifecycleOwner The [LifecycleOwner], whose Lifecycle is required by + * [FragmentStateAdapter]. + */ + private class HomePagerAdapter( + private val tabs: List, + fragmentManager: FragmentManager, + lifecycleOwner: LifecycleOwner + ) : + FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle ) { - override fun getItemCount(): Int = homeModel.tabs.size + override fun getItemCount() = tabs.size - override fun createFragment(position: Int): Fragment { - return when (homeModel.tabs[position]) { + override fun createFragment(position: Int): Fragment = + when (tabs[position]) { MusicMode.SONGS -> SongListFragment() MusicMode.ALBUMS -> AlbumListFragment() MusicMode.ARTISTS -> ArtistListFragment() MusicMode.GENRES -> GenreListFragment() } - } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 15634e5d3..31ef0b82c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -35,139 +35,185 @@ import org.oxycblt.auxio.util.application import org.oxycblt.auxio.util.logD /** - * The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state. - * @author OxygenCobalt + * The ViewModel for managing the tab data and lists of the home view. + * @author Alexander Capehart (OxygenCobalt) */ class HomeViewModel(application: Application) : AndroidViewModel(application), Settings.Callback, MusicStore.Callback { private val musicStore = MusicStore.getInstance() private val settings = Settings(application, this) - private val _songs = MutableStateFlow(listOf()) - val songs: StateFlow> - get() = _songs + private val _songsList = MutableStateFlow(listOf()) + /** + * A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. + */ + val songLists: StateFlow> + get() = _songsList - private val _albums = MutableStateFlow(listOf()) - val albums: StateFlow> - get() = _albums + private val _albumsLists = MutableStateFlow(listOf()) + /** + * A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. + */ + val albumsList: StateFlow> + get() = _albumsLists - private val _artists = MutableStateFlow(listOf()) - val artists: MutableStateFlow> - get() = _artists + private val _artistsList = MutableStateFlow(listOf()) + /** + * A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. + * Note that if "Hide collaborators" is on, this list will not include [Artist]s + * where [Artist.isCollaborator] is true. + */ + val artistsList: MutableStateFlow> + get() = _artistsList - private val _genres = MutableStateFlow(listOf()) - val genres: StateFlow> - get() = _genres - - var tabs: List = visibleTabs - private set - - /** Internal getter for getting the visible library tabs */ - private val visibleTabs: List - get() = settings.libTabs.filterIsInstance().map { it.mode } - - private val _currentTab = MutableStateFlow(tabs[0]) - val currentTab: StateFlow = _currentTab + private val _genresList = MutableStateFlow(listOf()) + /** + * A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. + */ + val genresList: StateFlow> + get() = _genresList /** - * Marker to recreate all library tabs, usually initiated by a settings change. When this flag - * is set, all tabs (and their respective viewpager fragments) will be recreated from scratch. + * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding + * invisible [Tab]s. */ - private val _shouldRecreateTabs = MutableStateFlow(false) - val recreateTabs: StateFlow = _shouldRecreateTabs + var currentTabModes: List = getVisibleTabModes() + private set + + private val _currentTabMode = MutableStateFlow(currentTabModes[0]) + /** + * The [MusicMode] of the currently shown [Tab]. + */ + val currentTabMode: StateFlow = _currentTabMode + + private val _shouldRecreate = MutableStateFlow(false) + /** + * A marker to re-create all library tabs, usually initiated by a settings change. + * When this flag is true, all tabs (and their respective ViewPager2 fragments) will be + * re-created from scratch. + */ + val shouldRecreate: StateFlow = _shouldRecreate private val _isFastScrolling = MutableStateFlow(false) + /** + * A marker for whether the user is fast-scrolling in the home view or not. + */ val isFastScrolling: StateFlow = _isFastScrolling init { musicStore.addCallback(this) } - /** Update the current tab based off of the new ViewPager position. */ - fun setCurrentTab(pos: Int) { - logD("Updating current tab to ${tabs[pos]}") - _currentTab.value = tabs[pos] - } - - fun finishRecreateTabs() { - _shouldRecreateTabs.value = false - } - - /** Get the specific sort for the given [MusicMode]. */ - fun getSortForTab(tabMode: MusicMode): Sort = - when (tabMode) { - MusicMode.SONGS -> settings.libSongSort - MusicMode.ALBUMS -> settings.libAlbumSort - MusicMode.ARTISTS -> settings.libArtistSort - MusicMode.GENRES -> settings.libGenreSort - } - - /** Update the currently displayed item's [Sort]. */ - fun updateCurrentSort(sort: Sort) { - logD("Updating ${_currentTab.value} sort to $sort") - when (_currentTab.value) { - MusicMode.SONGS -> { - settings.libSongSort = sort - _songs.value = sort.songs(_songs.value) - } - MusicMode.ALBUMS -> { - settings.libAlbumSort = sort - _albums.value = sort.albums(_albums.value) - } - MusicMode.ARTISTS -> { - settings.libArtistSort = sort - _artists.value = sort.artists(_artists.value) - } - MusicMode.GENRES -> { - settings.libGenreSort = sort - _genres.value = sort.genres(_genres.value) - } - } - } - - /** - * Update the fast scroll state. This is used to control the FAB visibility whenever the user - * begins to fast scroll. - */ - fun setFastScrolling(fastScrolling: Boolean) { - logD("Updating fast scrolling state: $fastScrolling") - _isFastScrolling.value = fastScrolling - } - - // --- OVERRIDES --- - - override fun onLibraryChanged(library: MusicStore.Library?) { - if (library != null) { - logD("Library changed, refreshing library") - _songs.value = settings.libSongSort.songs(library.songs) - _albums.value = settings.libAlbumSort.albums(library.albums) - - _artists.value = - settings.libArtistSort.artists( - if (settings.shouldHideCollaborators) { - library.artists.filter { !it.isCollaborator } - } else { - library.artists - }) - - _genres.value = settings.libGenreSort.genres(library.genres) - } - } - - override fun onSettingChanged(key: String) { - if (key == application.getString(R.string.set_key_lib_tabs)) { - tabs = visibleTabs - _shouldRecreateTabs.value = true - } - - if (key == application.getString(R.string.set_key_hide_collaborators)) { - onLibraryChanged(musicStore.library) - } - } - override fun onCleared() { super.onCleared() musicStore.removeCallback(this) settings.release() } + + override fun onLibraryChanged(library: MusicStore.Library?) { + if (library != null) { + logD("Library changed, refreshing library") + // Get the each list of items in the library to use as our list data. + // Applying the preferred sorting to them. + _songsList.value = settings.libSongSort.songs(library.songs) + _albumsLists.value = settings.libAlbumSort.albums(library.albums) + _artistsList.value = + settings.libArtistSort.artists( + if (settings.shouldHideCollaborators) { + // Hide Collaborators is enabled, filter out collaborators. + library.artists.filter { !it.isCollaborator } + } else { + library.artists + }) + _genresList.value = settings.libGenreSort.genres(library.genres) + } + } + + override fun onSettingChanged(key: String) { + when (key) { + application.getString(R.string.set_key_lib_tabs) -> { + // Tabs changed, update the current tabs and set up a re-create event. + currentTabModes = getVisibleTabModes() + _shouldRecreate.value = true + } + + application.getString(R.string.set_key_hide_collaborators) -> { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + onLibraryChanged(musicStore.library) + } + } + } + + /** + * Update [currentTabMode] to reflect a new ViewPager2 position + * @param pagerPos The new position of the ViewPager2 instance. + */ + fun synchronizeTabPosition(pagerPos: Int) { + logD("Updating current tab to ${currentTabModes[pagerPos]}") + _currentTabMode.value = currentTabModes[pagerPos] + } + + /** + * Mark the recreation process as completed, resetting [shouldRecreate]. + */ + fun finishRecreate() { + _shouldRecreate.value = false + } + + /** + * Get the preferred [Sort] for a given [Tab]. + * @param tabMode The [MusicMode] of the [Tab] desired. + * @return The [Sort] preferred for that [Tab] + */ + fun getSortForTab(tabMode: MusicMode) = + when (tabMode) { + MusicMode.SONGS -> settings.libSongSort + MusicMode.ALBUMS -> settings.libAlbumSort + MusicMode.ARTISTS -> settings.libArtistSort + MusicMode.GENRES -> settings.libGenreSort + } + + /** + * Update the preferred [Sort] for the current [Tab]. Will update corresponding list. + * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. + */ + fun setSortForCurrentTab(sort: Sort) { + logD("Updating ${_currentTabMode.value} sort to $sort") + // Can simply re-sort the current list of items without having to access the library. + when (_currentTabMode.value) { + MusicMode.SONGS -> { + settings.libSongSort = sort + _songsList.value = sort.songs(_songsList.value) + } + MusicMode.ALBUMS -> { + settings.libAlbumSort = sort + _albumsLists.value = sort.albums(_albumsLists.value) + } + MusicMode.ARTISTS -> { + settings.libArtistSort = sort + _artistsList.value = sort.artists(_artistsList.value) + } + MusicMode.GENRES -> { + settings.libGenreSort = sort + _genresList.value = sort.genres(_genresList.value) + } + } + } + + /** + * Update whether the user is fast scrolling or not in the home view. + * @param isFastScrolling true if the user is currently fast scrolling, false otherwise. + */ + fun setFastScrolling(isFastScrolling: Boolean) { + logD("Updating fast scrolling state: $isFastScrolling") + _isFastScrolling.value = isFastScrolling + } + + /** + * Get the [MusicMode]s of the visible [Tab]s from the [Tab] configuration. + * @return A list of [MusicMode]s for each visible [Tab] in the [Tab] configuration. + */ + private fun getVisibleTabModes() = + settings.libTabs.filterIsInstance().map { it.mode } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt index ad41c02f9..31a7f6495 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt @@ -39,8 +39,8 @@ import org.oxycblt.auxio.util.getDimenSize import org.oxycblt.auxio.util.isRtl /** - * Internal view responsible for the fast scroller popup. - * @author OxygenCobalt, Hai Zhang + * A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView + * @author Alexander Capehart (OxygenCobalt), Hai Zhang */ class FastScrollPopupView @JvmOverloads @@ -170,7 +170,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) } companion object { - // Pre-calculate sqrt(2) for faster drawing + // Pre-calculate sqrt(2) private const val SQRT2 = 1.4142135f } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 2d055d885..a77fe8691 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -67,14 +67,38 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * * TODO: Add vibration when popup changes * - * TODO: Improve support for variably sized items + * TODO: Improve support for variably sized items (Re-back with library fast scroller?) * - * @author Hai Zhang, OxygenCobalt + * @author Hai Zhang, Alexander Capehart (OxygenCobalt) */ class FastScrollRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : AuxioRecyclerView(context, attrs, defStyleAttr) { + /** + * An interface to provide text to use in the popup when fast-scrolling. + */ + interface PopupProvider { + /** + * Get text to use in the popup at the specified position. + * @param pos The position in the list. + * @return A [String] to use in the popup. Null if there is no applicable text for + * the popup at [pos]. + */ + fun getPopup(pos: Int): String? + } + + /** + * A listener for fast scroller interactions. + */ + interface Listener { + /** + * Called when the fast scrolling state changes. + * @param isFastScrolling true if the user is currently fast scrolling, false otherwise. + */ + fun onFastScrollingChanged(isFastScrolling: Boolean) + } + // Thumb private val thumbView = View(context).apply { @@ -142,21 +166,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr hidePopup() } - fastScrollCallback?.invoke(field) + listener?.onFastScrollingChanged(field) } private val tRect = Rect() - /** Callback to provide a string to be shown on the popup when an item is passed */ - var popupProvider: ((Int) -> String?)? = null - - class FastScrollCallback(val onStart: () -> Unit, val onEnd: () -> Unit) - - /** - * A callback for when a drag event occurs. The value will be true if a drag has begun, and - * false if a drag ended. - */ - var fastScrollCallback: ((Boolean) -> Unit)? = null + var popupProvider: PopupProvider? = null + var listener: Listener? = null init { overlay.add(thumbView) @@ -218,7 +234,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr if (firstAdapterPos != NO_POSITION && provider != null) { popupView.isInvisible = false // Get the popup text. If there is none, we default to "?". - popupText = provider.invoke(firstAdapterPos) ?: "?" + popupText = provider.getPopup(firstAdapterPos) ?: "?" } else { // No valid position or provider, do not show the popup. popupView.isInvisible = true 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 f4e19b192..febc48ebc 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 @@ -27,6 +27,7 @@ import java.util.Formatter import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.AlbumViewHolder @@ -42,16 +43,14 @@ import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately /** - * A [HomeListFragment] for showing a list of [Album]s. - * @author OxygenCobalt + * A [ListFragment] that shows a list of [Album]s. + * @author Alexander Capehart (OxygenCobalt) */ -class AlbumListFragment : ListFragment() { +class AlbumListFragment : ListFragment(), FastScrollRecyclerView.Listener, FastScrollRecyclerView.PopupProvider { private val homeModel: HomeViewModel by activityViewModels() - - private val homeAdapter = - AlbumAdapter(ItemSelectCallback(::handleClick, ::handleOpenMenu, ::handleClick)) - - private val formatterSb = StringBuilder(32) + private val albumAdapter = AlbumAdapter(this) + // Save memory by re-using the same formatter and string builder when creating popup text + private val formatterSb = StringBuilder(64) private val formatter = Formatter(formatterSb) override fun onCreateBinding(inflater: LayoutInflater) = @@ -62,36 +61,28 @@ class AlbumListFragment : ListFragment() { binding.homeRecycler.apply { id = R.id.home_album_recycler - adapter = homeAdapter - - popupProvider = ::updatePopup - fastScrollCallback = { homeModel.setFastScrolling(it) } + adapter = albumAdapter + popupProvider = this@AlbumListFragment + listener = this@AlbumListFragment } - collectImmediately(homeModel.albums, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.albumsList, albumAdapter::replaceList) + collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { super.onDestroyBinding(binding) - binding.homeRecycler.adapter = null + binding.homeRecycler.apply { + adapter = null + popupProvider = null + listener = null + } } - override fun onRealClick(music: Music) { - check(music is Album) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) - } - - private fun handleOpenMenu(item: Item, anchor: View) { - check(item is Album) { "Unexpected datatype: ${item::class.java}" } - openMusicMenu(anchor, R.menu.menu_album_actions, item) - } - - private fun updatePopup(pos: Int): String? { - val album = homeModel.albums.value[pos] - - // Change how we display the popup depending on the mode. + override fun getPopup(pos: Int): String? { + val album = homeModel.albumsList.value[pos] + // Change how we display the popup depending on the current sort mode. return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) { // By Name -> Use Name is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() } @@ -126,18 +117,38 @@ class AlbumListFragment : ListFragment() { else -> null } } - private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - if (parent is Album) { - homeAdapter.setPlayingItem(parent, isPlaying) - } else { - // Ignore playback not from albums - homeAdapter.setPlayingItem(null, isPlaying) - } + + override fun onFastScrollingChanged(isFastScrolling: Boolean) { + homeModel.setFastScrolling(isFastScrolling) } - private class AlbumAdapter(private val callback: ItemSelectCallback) : + override fun onRealClick(music: Music) { + check(music is Album) { "Unexpected datatype: ${music::class.java}" } + navModel.exploreNavigateTo(music) + } + + override fun onOpenMenu(item: Item, anchor: View) { + check(item is Album) { "Unexpected datatype: ${item::class.java}" } + openMusicMenu(anchor, R.menu.menu_album_actions, item) + } + + /** + * Update the current playback state in the context of the current [Album] list. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { + // If an album is playing, highlight it within this adapter. + albumAdapter.setPlayingItem(parent as? Album, isPlaying) + } + + /** + * A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder]. + * @param listener An [ExtendedListListener] for list interactions. + */ + private class AlbumAdapter(private val listener: ExtendedListListener) : SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER) + private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK) override val currentList: List get() = differ.currentList @@ -147,14 +158,14 @@ class AlbumListFragment : ListFragment() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AlbumViewHolder.new(parent) - override fun onBindViewHolder(holder: AlbumViewHolder, position: Int, payloads: List) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - holder.bind(differ.currentList[position], callback) - } + override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) { + holder.bind(differ.currentList[position], listener) } + /** + * Asynchronously update the list with new [Album]s. + * @param newList The new [Album]s for the adapter to display. + */ fun replaceList(newList: List) { differ.replaceList(newList) } 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 1407c21cc..22910817e 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 @@ -25,6 +25,7 @@ import androidx.fragment.app.activityViewModels import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.ArtistViewHolder @@ -40,14 +41,12 @@ import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.nonZeroOrNull /** - * A [HomeListFragment] for showing a list of [Artist]s. - * @author OxygenCobalt + * A [ListFragment] that shows a list of [Artist]s. + * @author Alexander Capehart (OxygenCobalt) */ -class ArtistListFragment : ListFragment() { +class ArtistListFragment : ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - - private val homeAdapter = - ArtistAdapter(ItemSelectCallback(::handleClick, ::handleOpenMenu, ::handleSelect)) + private val homeAdapter = ArtistAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -58,25 +57,27 @@ class ArtistListFragment : ListFragment() { binding.homeRecycler.apply { id = R.id.home_artist_recycler adapter = homeAdapter - - popupProvider = ::updatePopup - fastScrollCallback = homeModel::setFastScrolling + popupProvider = this@ArtistListFragment + listener = this@ArtistListFragment } - collectImmediately(homeModel.artists, homeAdapter::replaceList) + collectImmediately(homeModel.artistsList, homeAdapter::replaceList) collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { super.onDestroyBinding(binding) - binding.homeRecycler.adapter = null + binding.homeRecycler.apply { + adapter = null + popupProvider = null + listener = null + } } - private fun updatePopup(pos: Int): String? { - val artist = homeModel.artists.value[pos] - - // Change how we display the popup depending on the mode. + override fun getPopup(pos: Int): String? { + val artist = homeModel.artistsList.value[pos] + // Change how we display the popup depending on the current sort mode. return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) { // By Name -> Use Name is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() } @@ -92,28 +93,37 @@ class ArtistListFragment : ListFragment() { } } + override fun onFastScrollingChanged(isFastScrolling: Boolean) { + homeModel.setFastScrolling(isFastScrolling) + } + override fun onRealClick(music: Music) { check(music is Artist) { "Unexpected datatype: ${music::class.java}" } navModel.exploreNavigateTo(music) } - private fun handleOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { check(item is Artist) { "Unexpected datatype: ${item::class.java}" } openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + /** + * Update the current playback state in the context of the current [Artist] list. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - if (parent is Artist) { - homeAdapter.setPlayingItem(parent, isPlaying) - } else { - // Ignore playback not from artists - homeAdapter.setPlayingItem(null, isPlaying) - } + // If an artist is playing, highlight it within this adapter. + homeAdapter.setPlayingItem(parent as? Artist, isPlaying) } - private class ArtistAdapter(private val callback: ItemSelectCallback) : + /** + * A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder]. + * @param listener An [ExtendedListListener] for list interactions. + */ + private class ArtistAdapter(private val listener: ExtendedListListener) : SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER) + private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK) override val currentList: List get() = differ.currentList @@ -123,18 +133,14 @@ class ArtistListFragment : ListFragment() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ArtistViewHolder.new(parent) - override fun onBindViewHolder( - holder: ArtistViewHolder, - position: Int, - payloads: List - ) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - holder.bind(differ.currentList[position], callback) - } + override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { + holder.bind(differ.currentList[position], listener) } + /** + * Asynchronously update the list with new [Artist]s. + * @param newList The new [Artist]s for the adapter to display. + */ fun replaceList(newList: List) { differ.replaceList(newList) } 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 9315d2ef3..134494dda 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 @@ -25,6 +25,7 @@ import androidx.fragment.app.activityViewModels import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.GenreViewHolder @@ -39,14 +40,12 @@ import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately /** - * A [HomeListFragment] for showing a list of [Genre]s. - * @author OxygenCobalt + * A [ListFragment] that shows a list of [Genre]s. + * @author Alexander Capehart (OxygenCobalt) */ -class GenreListFragment : ListFragment() { +class GenreListFragment : ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener{ private val homeModel: HomeViewModel by activityViewModels() - - private val homeAdapter = - GenreAdapter(ItemSelectCallback(::handleClick, ::handleOpenMenu, ::handleSelect)) + private val homeAdapter = GenreAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -57,25 +56,27 @@ class GenreListFragment : ListFragment() { binding.homeRecycler.apply { id = R.id.home_genre_recycler adapter = homeAdapter - - popupProvider = ::updatePopup - fastScrollCallback = homeModel::setFastScrolling + popupProvider = this@GenreListFragment + listener = this@GenreListFragment } - collectImmediately(homeModel.genres, homeAdapter::replaceList) + collectImmediately(homeModel.genresList, homeAdapter::replaceList) collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } override fun onDestroyBinding(binding: FragmentHomeListBinding) { super.onDestroyBinding(binding) - binding.homeRecycler.adapter = null + binding.homeRecycler.apply { + adapter = null + popupProvider = null + listener = null + } } - private fun updatePopup(pos: Int): String? { - val genre = homeModel.genres.value[pos] - - // Change how we display the popup depending on the mode. + override fun getPopup(pos: Int): String? { + val genre = homeModel.genresList.value[pos] + // Change how we display the popup depending on the current sort mode. return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { // By Name -> Use Name is Sort.Mode.ByName -> genre.collationKey?.run { sourceString.first().uppercase() } @@ -91,28 +92,37 @@ class GenreListFragment : ListFragment() { } } + override fun onFastScrollingChanged(isFastScrolling: Boolean) { + homeModel.setFastScrolling(isFastScrolling) + } + override fun onRealClick(music: Music) { check(music is Genre) { "Unexpected datatype: ${music::class.java}" } navModel.exploreNavigateTo(music) } - private fun handleOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { check(item is Genre) { "Unexpected datatype: ${item::class.java}" } openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + /** + * Update the current playback state in the context of the current [Genre] list. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { - if (parent is Genre) { - homeAdapter.setPlayingItem(parent, isPlaying) - } else { - // Ignore playback not from genres - homeAdapter.setPlayingItem(null, isPlaying) - } + // If a genre is playing, highlight it within this adapter. + homeAdapter.setPlayingItem(parent as? Genre, isPlaying) } - private class GenreAdapter(private val callback: ItemSelectCallback) : + /** + * A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder]. + * @param listener An [ExtendedListListener] for list interactions. + */ + private class GenreAdapter(private val listener: ExtendedListListener) : SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER) + private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK) override val currentList: List get() = differ.currentList @@ -122,14 +132,14 @@ class GenreListFragment : ListFragment() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = GenreViewHolder.new(parent) - override fun onBindViewHolder(holder: GenreViewHolder, position: Int, payloads: List) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - holder.bind(differ.currentList[position], callback) - } + override fun onBindViewHolder(holder: GenreViewHolder, position: Int) { + holder.bind(differ.currentList[position], listener) } + /** + * Asynchronously update the list with new [Genre]s. + * @param newList The new [Genre]s for the adapter to display. + */ fun replaceList(newList: List) { differ.replaceList(newList) } 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 baca01114..7e03dd878 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 @@ -27,6 +27,7 @@ import java.util.Formatter import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter @@ -41,21 +42,16 @@ import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.context /** - * A [HomeListFragment] for showing a list of [Song]s. - * @author OxygenCobalt + * A [ListFragment] that shows a list of [Song]s. + * @author Alexander Capehart (OxygenCobalt) */ -class SongListFragment : ListFragment() { +class SongListFragment : ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - - private val homeAdapter = - SongAdapter(ItemSelectCallback(::handleClick, ::handleOpenMenu, ::handleSelect)) - - private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } - - private val formatterSb = StringBuilder(50) + private val homeAdapter = SongAdapter(this) + // Save memory by re-using the same formatter and string builder when creating popup text + private val formatterSb = StringBuilder(64) private val formatter = Formatter(formatterSb) override fun onCreateBinding(inflater: LayoutInflater) = @@ -67,12 +63,11 @@ class SongListFragment : ListFragment() { binding.homeRecycler.apply { id = R.id.home_song_recycler adapter = homeAdapter - - popupProvider = ::updatePopup - fastScrollCallback = homeModel::setFastScrolling + popupProvider = this@SongListFragment + listener = this@SongListFragment } - collectImmediately(homeModel.songs, homeAdapter::replaceList) + collectImmediately(homeModel.songLists, homeAdapter::replaceList) collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) @@ -80,13 +75,16 @@ class SongListFragment : ListFragment() { override fun onDestroyBinding(binding: FragmentHomeListBinding) { super.onDestroyBinding(binding) - binding.homeRecycler.adapter = null + binding.homeRecycler.apply { + adapter = null + popupProvider = null + listener = null + } } - private fun updatePopup(pos: Int): String? { - val song = homeModel.songs.value[pos] - - // Change how we display the popup depending on the mode. + override fun getPopup(pos: Int): String? { + val song = homeModel.songLists.value[pos] + // Change how we display the popup depending on the current sort mode. // Note: We don't use the more correct individual artist name here, as sorts are largely // based off the names of the parent objects and not the child objects. return when (homeModel.getSortForTab(MusicMode.SONGS).mode) { @@ -125,21 +123,30 @@ class SongListFragment : ListFragment() { } } + override fun onFastScrollingChanged(isFastScrolling: Boolean) { + homeModel.setFastScrolling(isFastScrolling) + } + override fun onRealClick(music: Music) { check(music is Song) { "Unexpected datatype: ${music::class.java}" } - when (settings.libPlaybackMode) { + when (val mode = Settings(requireContext()).libPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(music) MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") + else -> error("Unexpected playback mode: $mode") } } - private fun handleOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { check(item is Song) { "Unexpected datatype: ${item::class.java}" } openMusicMenu(anchor, R.menu.menu_song_actions, item) } + /** + * Update the current playback state in the context of the current [Song] list. + * @param parent The current [MusicParent] playing, null if all songs. + * @param isPlaying Whether playback is ongoing or paused. + */ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { if (parent == null) { homeAdapter.setPlayingItem(song, isPlaying) @@ -149,9 +156,13 @@ class SongListFragment : ListFragment() { } } - private class SongAdapter(private val callback: ItemSelectCallback) : + /** + * A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder]. + * @param listener An [ExtendedListListener] for list interactions. + */ + private class SongAdapter(private val listener: ExtendedListListener) : SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, SongViewHolder.DIFFER) + private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK) override val currentList: List get() = differ.currentList @@ -161,14 +172,14 @@ class SongListFragment : ListFragment() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SongViewHolder.new(parent) - override fun onBindViewHolder(holder: SongViewHolder, position: Int, payloads: List) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - holder.bind(differ.currentList[position], callback) - } + override fun onBindViewHolder(holder: SongViewHolder, position: Int) { + holder.bind(differ.currentList[position], listener) } + /** + * Asynchronously update the list with new [Song]s. + * @param newList The new [Song]s for the adapter to display. + */ fun replaceList(newList: List) { differ.replaceList(newList) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt similarity index 75% rename from app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt rename to app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 148d37e89..276a52a40 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -15,33 +15,32 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.home +package org.oxycblt.auxio.home.tabs import android.content.Context import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.logD /** - * A tag configuration strategy that automatically adapts the tab layout to the screen size. - * - On small screens, use only an icon - * - On medium screens, use only text - * - On large screens, use text and an icon - * @author OxygenCobalt + * A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations + * depending on the screen configuration. + * @param context [Context] required to obtain window information + * @param tabs Current tab configuration from settings + * @author Alexander Capehart (OxygenCobalt) */ -class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) : +class AdaptiveTabStrategy(context: Context, private val tabs: List) : TabLayoutMediator.TabConfigurationStrategy { private val width = context.resources.configuration.smallestScreenWidthDp override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val tabMode = homeModel.tabs[position] - val icon: Int val string: Int - when (tabMode) { + when (tabs[position]) { MusicMode.SONGS -> { icon = R.drawable.ic_song_24 string = R.string.lbl_songs @@ -60,15 +59,19 @@ class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel } } + // Use expected sw* size thresholds when choosing a configuration. when { + // On small screens, only display an icon. width < 370 -> { logD("Using icon-only configuration") tab.setIcon(icon).setContentDescription(string) } + // On large screens, display an icon and text. width < 600 -> { logD("Using text-only configuration") tab.setText(string) } + // On medium-size screens, display text. else -> { logD("Using icon-and-text configuration") tab.setIcon(icon).setText(string) diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 63f0989cd..80ba01403 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -25,37 +25,48 @@ import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.logE /** - * A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible]. - * Invisibility means that the tab will still be present in the customization menu, but will not be - * shown on the home UI. - * - * Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs cannot - * be serialized on their own. Instead, they are saved as a sequence of tabs as shown below: - * - * 0bTAB1_TAB2_TAB3_TAB4_TAB5 - * - * Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. Each - * chunk in a sequence is represented as: - * - * VTTT - * - * Where V is a bit representing the visibility and T is a 3-bit integer representing the - * [MusicMode] ordinal for this tab. - * - * To serialize and deserialize a tab sequence, [toSequence] and [fromSequence] can be used - * respectively. - * - * By default, the tab order will be SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS + * A representation of a library tab suitable for configuration. + * @param mode The type of list in the home view this instance corresponds to. + * @author Alexander Capehart (OxygenCobalt) */ sealed class Tab(open val mode: MusicMode) { + /** + * A visible tab. This will be visible in the home and tab configuration views. + * @param mode The type of list in the home view this instance corresponds to. + */ data class Visible(override val mode: MusicMode) : Tab(mode) + + /** + * A visible tab. This will be visible in the tab configuration view, but not in the + * home view. + * @param mode The type of list in the home view this instance corresponds to. + */ data class Invisible(override val mode: MusicMode) : Tab(mode) companion object { - /** The length a well-formed tab sequence should be */ + // Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs + // cannot be serialized on their own. Instead, they are saved as a sequence of tabs as shown + // below: + // + // 0bTAB1_TAB2_TAB3_TAB4_TAB5 + // + // Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. + // Each chunk in a sequence is represented as: + // + // VTTT + // + // Where V is a bit representing the visibility and T is a 3-bit integer representing the + // MusicMode for this tab. + + /** + * The length a well-formed tab sequence should be + */ private const val SEQUENCE_LEN = 4 - /** The default tab sequence, represented in integer form */ + /** + * The default tab sequence, in integer form. + * This will be SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS. + */ const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100 /** @@ -64,7 +75,11 @@ sealed class Tab(open val mode: MusicMode) { private val MODE_TABLE = arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES) - /** Convert an array [tabs] into a sequence of tabs. */ + /** + * Convert an array of tabs into it's integer representation. + * @param tabs The array of tabs to convert + * @return An integer representation of the tab array + */ fun toSequence(tabs: Array): Int { // Like when deserializing, make sure there are no duplicate tabs for whatever reason. val distinct = tabs.distinctBy { it.mode } @@ -86,7 +101,11 @@ sealed class Tab(open val mode: MusicMode) { return sequence } - /** Convert a [sequence] into an array of tabs. */ + /** + * Convert a tab integer representation into an array of tabs. + * @param sequence The integer representation of the tabs. + * @return An array of tabs corresponding to the sequence. + */ fun fromSequence(sequence: Int): Array? { val tabs = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index ba5728a34..57a5a1777 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt @@ -24,11 +24,36 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemTabBinding -import org.oxycblt.auxio.list.recycler.DialogViewHolder +import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.inflater -class TabAdapter(private val callback: Callback) : RecyclerView.Adapter() { +/** + * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. + * @param listener A [Listener] for tab interactions. + */ +class TabAdapter(private val listener: Listener) : RecyclerView.Adapter() { + /** + * A listener for interactions specific to tab configuration. + */ + interface Listener { + /** + * Called when a tab is clicked, requesting that the visibility should be inverted + * (i.e Visible -> Invisible and vice versa). + * @param tabMode The [MusicMode] of the tab clicked. + */ + fun onToggleVisibility(tabMode: MusicMode) + + /** + * Called when the drag handle is pressed, requesting that a drag should be started. + * @param viewHolder The [RecyclerView.ViewHolder] to start dragging. + */ + fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) + } + + /** + * The current array of [Tab]s. + */ var tabs = arrayOf() private set @@ -37,66 +62,94 @@ class TabAdapter(private val callback: Callback) : RecyclerView.Adapter) { tabs = newTabs notifyDataSetChanged() } + /** + * Update a specific tab to the given value. + * @param at The position of the tab to update. + * @param tab The new tab. + */ fun setTab(at: Int, tab: Tab) { tabs[at] = tab + // Use a payload to avoid an item change animation. notifyItemChanged(at, PAYLOAD_TAB_CHANGED) } - fun moveItems(from: Int, to: Int) { - val t = tabs[to] - val f = tabs[from] - tabs[from] = t - tabs[to] = f - notifyItemMoved(from, to) + /** + * Swap two tabs with eachother. + * @param a The position of the first tab to swap. + * @param b The position of the second tab to swap. + */ + fun swapTabs(a: Int, b: Int) { + val tmp = tabs[b] + tabs[b] = tabs[a] + tabs[a] = tmp + notifyItemMoved(a, b) } - class Callback( - val toggleVisibility: (MusicMode) -> Unit, - val pickUpTab: (RecyclerView.ViewHolder) -> Unit - ) - companion object { - val PAYLOAD_TAB_CHANGED = Any() + private val PAYLOAD_TAB_CHANGED = Any() } } +/** + * A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) + */ class TabViewHolder private constructor(private val binding: ItemTabBinding) : - DialogViewHolder(binding.root) { + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * @param tab The new [Tab] to bind. + * @param listener An [TabAdapter.Listener] to bind interactions to. + */ @SuppressLint("ClickableViewAccessibility") - fun bind(item: Tab, callback: TabAdapter.Callback) { - binding.root.setOnClickListener { callback.toggleVisibility(item.mode) } + fun bind(tab: Tab, listener: TabAdapter.Listener) { + binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) } - binding.tabIcon.apply { + binding.tabCheckBox.apply { + // Update the CheckBox name to align with the mode setText( - when (item.mode) { + when (tab.mode) { MusicMode.SONGS -> R.string.lbl_songs MusicMode.ALBUMS -> R.string.lbl_albums MusicMode.ARTISTS -> R.string.lbl_artists MusicMode.GENRES -> R.string.lbl_genres }) - isChecked = item is Tab.Visible + + // Unlike in other adapters, we update the checked state alongside + // the tab data since they are in the same data structure (Tab) + isChecked = tab is Tab.Visible } - // Roll our own drag handlers as the default ones suck + // Set up the drag handle to start a drag whenever it is touched. binding.tabDragHandle.setOnTouchListener { _, motionEvent -> binding.tabDragHandle.performClick() if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { - callback.pickUpTab(this) + listener.onPickUpTab(this) true } else false } } companion object { + + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater)) } } 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 37885464d..b1228872f 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 @@ -32,12 +32,13 @@ import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD /** - * The dialog for customizing library tabs. - * @author OxygenCobalt + * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. + * @author Alexander Capehart (OxygenCobalt) */ -class TabCustomizeDialog : ViewBindingDialogFragment() { - private val tabAdapter = TabAdapter(TabAdapter.Callback(::toggleVisibility, ::pickUpTab)) +class TabCustomizeDialog : ViewBindingDialogFragment(), TabAdapter.Listener { private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } + + private val tabAdapter = TabAdapter(this) private val touchHelper: ItemTouchHelper by lifecycleObject { ItemTouchHelper(TabDragCallback(tabAdapter)) } @@ -55,14 +56,17 @@ class TabCustomizeDialog : ViewBindingDialogFragment() { } override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { - val savedTabs = findSavedTabState(savedInstanceState) - if (savedTabs != null) { - logD("Found saved tab state") - tabAdapter.submitTabs(savedTabs) - } else { - tabAdapter.submitTabs(settings.libTabs) + var tabs = settings.libTabs + // Try to restore a pending tab configuration that was saved prior. + if (savedInstanceState != null) { + val savedTabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) + if (savedTabs != null) { + tabs = savedTabs + } } + // Set up the tab RecyclerView + tabAdapter.submitTabs(tabs) binding.tabRecycler.apply { adapter = tabAdapter touchHelper.attachToRecyclerView(this) @@ -71,6 +75,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) + // Save any pending tab configurations to restore from when this dialog is re-created. outState.putInt(KEY_TABS, Tab.toSequence(tabAdapter.tabs)) } @@ -79,35 +84,30 @@ class TabCustomizeDialog : ViewBindingDialogFragment() { binding.tabRecycler.adapter = null } - private fun toggleVisibility(mode: MusicMode) { - val index = tabAdapter.tabs.indexOfFirst { it.mode == mode } - if (index > -1) { - val tab = tabAdapter.tabs[index] - tabAdapter.setTab( - index, - when (tab) { - is Tab.Visible -> Tab.Invisible(tab.mode) - is Tab.Invisible -> Tab.Visible(tab.mode) - }) - } + override fun onToggleVisibility(tabMode: MusicMode) { + logD("Toggling tab $tabMode") + // We will need the exact index of the tab to update on in order to + // notify the adapter of the change. + val index = tabAdapter.tabs.indexOfFirst { it.mode == tabMode } + val tab = tabAdapter.tabs[index] + tabAdapter.setTab( + index, + when (tab) { + // Invert the visibility of the tab + is Tab.Visible -> Tab.Invisible(tab.mode) + is Tab.Invisible -> Tab.Visible(tab.mode) + }) + + // Prevent the user from saving if all the tabs are Invisible, as that's an invalid state. (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = tabAdapter.tabs.filterIsInstance().isNotEmpty() } - private fun pickUpTab(viewHolder: RecyclerView.ViewHolder) { + override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) { touchHelper.startDrag(viewHolder) } - private fun findSavedTabState(savedInstanceState: Bundle?): Array? { - if (savedInstanceState != null) { - // Restore any pending tab configurations - return Tab.fromSequence(savedInstanceState.getInt(KEY_TABS)) - } - - return null - } - companion object { const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS" } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt index a0810be11..fd70d4be1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt @@ -22,14 +22,15 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView /** - * A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu. - * Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. + * An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter]. + * @author Alexander Capehart (OxygenCobalt) */ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder - ): Int = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) + ) = // Allow dragging up and down only + makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) override fun onChildDraw( c: Canvas, @@ -40,8 +41,6 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac actionState: Int, isCurrentlyActive: Boolean ) { - // No fancy UI magic here. This is a dialog, we don't need to give it as much attention. - // Just make sure the built-in androidx code doesn't get in our way. viewHolder.itemView.translationX = dX viewHolder.itemView.translationY = dY } @@ -56,7 +55,9 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { - adapter.moveItems(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + // I don't think it's possible to jump more than one position at a time, so a swap + // will work just fine. + adapter.swapTabs(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) return true } diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index 295e9a45a..f0662d6c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -28,17 +28,46 @@ import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.Song /** - * A utility to provide bitmaps in a manner less prone to race conditions. + * A utility to provide bitmaps in a race-less manner. * - * Pretty much each service component needs to load bitmaps of some kind, but doing a blind image - * request with some target callbacks could result in overlapping requests causing incorrect - * updates. This class (to an extent) resolves this by adding a concurrency guard to the image - * callbacks. + * When it comes to components that load images manually as [Bitmap] instances, queued + * [ImageRequest]s may cause a race condition that results in the incorrect image being + * drawn. This utility resolves this by keeping track of the current request, and disposing + * it as soon as a new request is queued or if another, competing request is newer. * - * @author OxygenCobalt + * @param context [Context] required to load images. + * @author Alexander Capehart (OxygenCobalt) */ class BitmapProvider(private val context: Context) { + /** + * An extension of [Disposable] with an additional [Target] to deliver the final [Bitmap] to. + */ + private data class Request(val disposable: Disposable, val callback: Target) + + /** + * The target that will recieve the requested [Bitmap]. + */ + interface Target { + /** + * Configure the [ImageRequest.Builder] to enable [Target]-specific configuration. + * @param builder The [ImageRequest.Builder] that will be used to request the + * desired [Bitmap]. + * @return The same [ImageRequest.Builder] in order to easily chain configuration + * methods. + */ + fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder + + /** + * Called when the loading process is completed. + * @param bitmap The loaded bitmap, or null if the bitmap could not be loaded. + */ + fun onCompleted(bitmap: Bitmap?) + } + private var currentRequest: Request? = null + // Keeps track of the current image request we are on. If the stored handle in an + // ImageRequest is still equal to this, it means that the request has not been + // superceded by a new one. private var currentHandle = 0L private var handleLock = Any() @@ -47,13 +76,15 @@ class BitmapProvider(private val context: Context) { get() = currentRequest?.run { !disposable.isDisposed } ?: false /** - * Load a bitmap from [song]. [target] should be a new object, not a reference to an existing - * callback. + * Load the Album cover [Bitmap] from a [Song]. + * @param song The song to load a [Bitmap] of it's album cover from. + * @param target The [Target] to deliver the [Bitmap] to asynchronously. */ @Synchronized fun load(song: Song, target: Target) { + // Increment the handle, indicating a newer request being created. val handle = synchronized(handleLock) { ++currentHandle } - + // Be even safer and cancel the previous request. currentRequest?.run { disposable.dispose() } currentRequest = null @@ -61,11 +92,16 @@ class BitmapProvider(private val context: Context) { target.onConfigRequest( ImageRequest.Builder(context) .data(song) + // Use ORIGINAL sizing, as we are not loading into any View-like component. .size(Size.ORIGINAL) + .transformations(SquareFrameTransform.INSTANCE)) + // Override the target in order to deliver the bitmap to the given + // callback. .target( onSuccess = { synchronized(handleLock) { if (currentHandle == handle) { + // Still the active request, deliver it to the target. target.onCompleted(it.toBitmap()) } } @@ -73,36 +109,23 @@ class BitmapProvider(private val context: Context) { onError = { synchronized(handleLock) { if (currentHandle == handle) { + // Still the active request, deliver it to the target. target.onCompleted(null) } } }) - .transformations(SquareFrameTransform.INSTANCE)) currentRequest = Request(context.imageLoader.enqueue(request.build()), target) } /** - * Release this instance, canceling all image load jobs. This should be ran when the object is - * no longer used. + * Release this instance. Run this when the object is no longer used to prevent + * stray loading callbacks. */ @Synchronized fun release() { + synchronized(handleLock) { ++currentHandle } currentRequest?.run { disposable.dispose() } currentRequest = null } - - private data class Request(val disposable: Disposable, val callback: Target) - - /** Represents the target for a request. */ - interface Target { - /** Modify the default request with custom attributes. */ - fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder - - /** - * Called when the loading process is completed. [bitmap] will be null if there was an - * error. - */ - fun onCompleted(bitmap: Bitmap?) - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt index 246067104..f66d14d33 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt @@ -21,13 +21,25 @@ import org.oxycblt.auxio.IntegerTable /** * Represents the options available for album cover loading. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ enum class CoverMode { + /** + * Do not load album covers ("Off"). + */ OFF, + /** + * Load covers from the fast, but lower-quality media store database ("Fast"). + */ MEDIA_STORE, + /** + * Load high-quality covers directly from music files ("Quality"). + */ QUALITY; + /** + * The integer representation of this instance. + */ val intCode: Int get() = when (this) { @@ -37,6 +49,11 @@ enum class CoverMode { } companion object { + /** + * Convert a [CoverMode], integer representation into an instance. + * @param intCode An integer representation of a [CoverMode] + * @return The corresponding [CoverMode], or null if the [CoverMode] is invalid. + */ fun fromIntCode(intCode: Int) = when (intCode) { IntegerTable.COVER_MODE_OFF -> OFF diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt index ef8bfbd4e..0f0437591 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt @@ -38,29 +38,35 @@ import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDimenSize /** - * Effectively a super-charged [StyledImageView]. - * - * This class enables the following features alongside the base features pf [StyledImageView]: - * - Selection indicator - * - (Eventually) activation indicator + * A super-charged [StyledImageView]. This class enables the following features in addition + * to [StyledImageView]: + * - A selection indicator + * - An activation (playback) indicator * - Support for ONE custom view * - * This class is primarily intended for list items. For most uses, the simpler [StyledImageView] is - * more efficient and suitable. + * This class is primarily intended for list items. For other uses, [StyledImageView] is more + * suitable. * - * @author OxygenCobalt + * TODO: Rework content descriptions here + * + * @author Alexander Capehart (OxygenCobalt) */ class ImageGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { - private val cornerRadius: Float - private val inner: StyledImageView + // Most attributes are simply handled by StyledImageView. + private val innerImageView: StyledImageView + // The custom view is populated when the layout inflates. private var customView: View? = null - private val playingIndicator: IndicatorView - private val selectionIndicator: ImageView - + // PlaybackIndicatorView overlays on top of the StyledImageView and custom view. + private val playbackIndicatorView: PlaybackIndicatorView + // The selection indicator view overlays all previous views. + private val selectionIndicatorView: ImageView + // Animator to handle selection visibility animations private var fadeAnimator: ValueAnimator? = null + // Keep track of our corner radius so that we can apply the same attributes to the custom view. + private val cornerRadius: Float init { // Android wants you to make separate attributes for each view type, but will @@ -70,26 +76,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) styledAttrs.recycle() - inner = StyledImageView(context, attrs) - playingIndicator = - IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius } - selectionIndicator = + // Initialize what views we can here. + innerImageView = StyledImageView(context, attrs) + playbackIndicatorView = + PlaybackIndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius } + selectionIndicatorView = ImageView(context).apply { imageTintList = context.getAttrColorCompat(R.attr.colorOnPrimary) setImageResource(R.drawable.ic_check_20) setBackgroundResource(R.drawable.ui_selection_badge_bg) } - addView(inner) + addView(innerImageView) } override fun onFinishInflate() { super.onFinishInflate() + // Due to innerImageView, the max child count is actually 2 and not 1. + check(childCount < 3) { "Only one custom view is allowed" } - if (childCount > 2) { - error("Only one custom view is allowed") - } - + // Get the second inflated child, if it exists, and then customize it to + // act like the other components in this view. customView = getChildAt(1)?.apply { background = @@ -99,10 +106,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } } - addView(playingIndicator) + // Add the other two views to complete the layering. + addView(playbackIndicatorView) addView( - selectionIndicator, + selectionIndicatorView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + // Override the layout params of the indicator so that it's in the + // bottom left corner. gravity = Gravity.BOTTOM or Gravity.END val spacing = context.getDimenSize(R.dimen.spacing_tiny) updateMarginsRelative(bottom = spacing, end = spacing) @@ -111,6 +121,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr override fun onAttachedToWindow() { super.onAttachedToWindow() + // Initialize each component before this view is drawn. invalidateAlpha() invalidatePlayingIndicator() invalidateSelectionIndicator() @@ -133,86 +144,117 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr invalidatePlayingIndicator() } + /** + * Bind a [Song] to the internal [StyledImageView]. + * @param song The [Song] to bind to the view. + * @see StyledImageView.bind + */ + fun bind(song: Song) = innerImageView.bind(song) + + /** + * Bind a [Album] to the internal [StyledImageView]. + * @param album The [Album] to bind to the view. + * @see StyledImageView.bind + */ + fun bind(album: Album) = innerImageView.bind(album) + + /** + * Bind a [Genre] to the internal [StyledImageView]. + * @param artist The [Artist] to bind to the view. + * @see StyledImageView.bind + */ + fun bind(artist: Artist) = innerImageView.bind(artist) + + /** + * Bind a [Genre] to the internal [StyledImageView]. + * @param genre The [Genre] to bind to the view. + * @see StyledImageView.bind + */ + fun bind(genre: Genre) = innerImageView.bind(genre) + + /** + * Whether this view should be indicated to have ongoing playback or not. See + * PlaybackIndicatorView for more information on what occurs here. + * Note: It's expected for this view to already be marked as playing with setSelected + * (not the same thing) before this is set to true. + */ var isPlaying: Boolean - get() = playingIndicator.isPlaying + get() = playbackIndicatorView.isPlaying set(value) { - playingIndicator.isPlaying = value + playbackIndicatorView.isPlaying = value } + /** + * Invalidate the overall opacity of this view. + */ private fun invalidateAlpha() { + // If this view is disabled, show it at half-opacity, *unless* it is also marked + // as playing, in which we still want to show it at full-opacity. alpha = if (isSelected || isEnabled) 1f else 0.5f } + /** + * Invalidate the view's playing ([isSelected]) indicator. + */ private fun invalidatePlayingIndicator() { if (isSelected) { + // View is "selected" (actually marked as playing), so show the playing indicator + // and hide all other elements except for the selection indicator. // TODO: Animate the other indicators? customView?.alpha = 0f - inner.alpha = 0f - playingIndicator.alpha = 1f + innerImageView.alpha = 0f + playbackIndicatorView.alpha = 1f } else { + // View is not "selected", hide the playing indicator. customView?.alpha = 1f - inner.alpha = 1f - playingIndicator.alpha = 0f + innerImageView.alpha = 1f + playbackIndicatorView.alpha = 0f } } + /** + * Invalidate the view's selection ([isActivated]) indicator, animating it from invisible + * to visible (or vice versa). + */ private fun invalidateSelectionIndicator() { + // Set up a target transition for the selection indicator. val targetAlpha: Float val targetDuration: Long if (isActivated) { + // Activated -> Show selection indicator targetAlpha = 1f targetDuration = context.resources.getInteger(R.integer.anim_fade_enter_duration).toLong() } else { + // Activated -> Hide selection indicator. targetAlpha = 0f targetDuration = context.resources.getInteger(R.integer.anim_fade_exit_duration).toLong() } - if (selectionIndicator.alpha == targetAlpha) { + if (selectionIndicatorView.alpha == targetAlpha) { + // Nothing to do. return } if (!isLaidOut) { - selectionIndicator.alpha = targetAlpha + // Not laid out, initialize it without animation before drawing. + selectionIndicatorView.alpha = targetAlpha return } if (fadeAnimator != null) { + // Cancel any previous animation. fadeAnimator?.cancel() fadeAnimator = null } fadeAnimator = - ValueAnimator.ofFloat(selectionIndicator.alpha, targetAlpha).apply { + ValueAnimator.ofFloat(selectionIndicatorView.alpha, targetAlpha).apply { duration = targetDuration - addUpdateListener { selectionIndicator.alpha = it.animatedValue as Float } + addUpdateListener { selectionIndicatorView.alpha = it.animatedValue as Float } start() } } - - fun bind(song: Song) { - inner.bind(song) - contentDescription = - context.getString(R.string.desc_album_cover, song.album.resolveName(context)) - } - - fun bind(album: Album) { - inner.bind(album) - contentDescription = - context.getString(R.string.desc_album_cover, album.resolveName(context)) - } - - fun bind(artist: Artist) { - inner.bind(artist) - contentDescription = - context.getString(R.string.desc_artist_image, artist.resolveName(context)) - } - - fun bind(genre: Genre) { - inner.bind(genre) - contentDescription = - context.getString(R.string.desc_genre_image, genre.resolveName(context)) - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt similarity index 78% rename from app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt rename to app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt index 64abd196e..900c49a3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt @@ -33,27 +33,36 @@ import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat /** - * View that displays the playback indicator. Nominally emulates [StyledImageView], but relies on - * the existing ImageView infrastructure to achieve the same result while also allowing animation to - * work. - * @author OxygenCobalt + * A view that displays an activation (i.e playback) indicator, with an accented styling and + * an animated equalizer icon. + * + * This is only meant for use with [ImageGroup]. Due to limitations with [AnimationDrawable] + * instances within custom views, this cannot be merged with [ImageGroup]. + * + * @author Alexander Capehart (OxygenCobalt) */ -class IndicatorView +class PlaybackIndicatorView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr) { + // The playing drawable will cycle through an active equalizer animation. private val playingIndicatorDrawable = context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable - + // The paused drawable will be a static drawable of an inactive equalizer. private val pausedIndicatorDrawable = context.getDrawableCompat(R.drawable.ic_paused_indicator_24) + // Required transformation matrices for the drawables. private val indicatorMatrix = Matrix() private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() private val settings = Settings(context) + /** + * The corner radius of this view. This allows the outer ImageGroup to apply it's + * corner radius to this view without any attribute hacks. + */ var cornerRadius = 0f set(value) { field = value @@ -66,6 +75,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } } + /** + * Whether this view should be indicated to have ongoing playback or not. If true, + * the animated playing icon will be shown. If false, the static paused icon will be shown. + */ var isPlaying: Boolean get() = drawable == playingIndicatorDrawable set(value) { @@ -79,6 +92,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } init { + // We will need to manually re-scale the playing/paused drawables to align with + // StyledDrawable, so use the matrix scale type. + scaleType = ScaleType.MATRIX + // Tint the playing/paused drawables so they are harmonious with the background. + ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg)) + // Use clipToOutline and a background drawable to crop images. While Coil's transformation // could theoretically be used to round corners, the corner radius is dependent on the // dimensions of the image, which will result in inconsistent corners across different @@ -91,9 +110,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr fillColor = context.getColorCompat(R.color.sel_cover_bg) setCornerSize(cornerRadius) } - - scaleType = ScaleType.MATRIX - ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg)) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -101,7 +117,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // Emulate StyledDrawable scaling with matrix scaling. val iconSize = max(measuredWidth, measuredHeight) / 2 - imageMatrix = indicatorMatrix.apply { reset() @@ -116,8 +131,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr indicatorMatrix.setRectToRect( indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER) - // Then actually center it into the icon, which the previous call does not - // actually do. + // Then actually center it into the icon. indicatorMatrix.postTranslate( (measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index 44cfc81e6..a137e7f3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -44,41 +44,33 @@ import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat /** - * An [AppCompatImageView] that applies many of the stylistic choices that Auxio uses regarding - * images. + * An [AppCompatImageView] with some additional styling, including: * - * Default behavior includes the addition of a tonal background, automatic sizing of icons to half - * of the view size, and corner radius application depending on user preference. + * - Tonal background + * - Rounded corners based on user preferences + * - Built-in support for binding image data or using a static icon with the same + * styling as placeholder drawables. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class StyledImageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr) { - private val settings = Settings(context) - - private var cornerRadius = 0f - set(value) { - field = value - (background as? MaterialShapeDrawable)?.let { bg -> - if (settings.roundMode) { - bg.setCornerSize(value) - } else { - bg.setCornerSize(0f) - } - } - } - - var staticIcon: Drawable? = null - set(value) { - field = value?.let { StyledDrawable(context, it) } - setImageDrawable(field) - } - - private var useLargeIcon: Boolean = false - init { + // Load view attributes + val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) + val staticIcon = + styledAttrs.getResourceId( + R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL) + val cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) + styledAttrs.recycle() + + if (staticIcon != ResourcesCompat.ID_NULL) { + // Use the static icon if specified for this image. + setImageDrawable(StyledDrawable(context, context.getDrawableCompat(staticIcon))) + } + // Use clipToOutline and a background drawable to crop images. While Coil's transformation // could theoretically be used to round corners, the corner radius is dependent on the // dimensions of the image, which will result in inconsistent corners across different @@ -89,71 +81,90 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr background = MaterialShapeDrawable().apply { fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadius) + if (Settings(context).roundMode) { + // Only use the specified corner radius when round mode is enabled. + setCornerSize(cornerRadius) + } } - - val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) - val staticIcon = - styledAttrs.getResourceId( - R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL) - if (staticIcon != ResourcesCompat.ID_NULL) { - this.staticIcon = context.getDrawableCompat(staticIcon) - } - - useLargeIcon = styledAttrs.getBoolean(R.styleable.StyledImageView_useLargeIcon, false) - - cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f) - styledAttrs.recycle() } - /** Bind the album cover for a [song]. */ - fun bind(song: Song) = loadImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover) + /** + * Bind a [Song]'s album cover to this view, also updating the content description. + * @param song The [Song] to bind. + */ + fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover) - /** Bind the album cover for an [album]. */ - fun bind(album: Album) = loadImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover) + /** + * Bind an [Album]'s cover to this view, also updating the content description. + * @param album the [Album] to bind. + */ + fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover) - /** Bind the image for an [artist] */ - fun bind(artist: Artist) = loadImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) + /** + * Bind an [Artist]'s image to this view, also updating the content description. + * @param artist the [Artist] to bind. + */ + fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image) - /** Bind the image for a [genre] */ - fun bind(genre: Genre) = loadImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) - - private fun loadImpl(music: T, @DrawableRes error: Int, @StringRes desc: Int) { - if (staticIcon != null) { - error("Static StyledImageViews cannot bind new images") - } - - contentDescription = context.getString(desc, music.resolveName(context)) + /** + * Bind an [Genre]'s image to this view, also updating the content description. + * @param genre the [Genre] to bind. + */ + fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image) + /** + * Internally bind a [Music]'s image to this view. + * @param music The music to find. + * @param errorRes The error drawable resource to use if the music cannot be loaded. + * @param descRes The content description string resource to use. The resource must have + * one field for the name of the [Music]. + */ + private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) { + // Dispose of any previous image request and load a new image. dispose() load(music) { - error(StyledDrawable(context, context.getDrawableCompat(error))) + error(StyledDrawable(context, context.getDrawableCompat(errorRes))) transformations(SquareFrameTransform.INSTANCE) } + + // Update the content description to the specified resource. + contentDescription = context.getString(descRes, music.resolveName(context)) } - private class StyledDrawable(context: Context, private val src: Drawable) : Drawable() { + /** + * A [Drawable] wrapper that re-styles the drawable to better align with the style + * of [StyledImageView]. + * @param context [Context] required for initialization. + * @param inner The [Drawable] to wrap. + */ + private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() { init { - DrawableCompat.setTintList(src, context.getColorCompat(R.color.sel_on_cover_bg)) + // Re-tint the drawable to use the analogous "on surface" color for + // StyledImageView. + DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) } override fun draw(canvas: Canvas) { + // Resize the drawable such that it's always 1/4 the size of the image and + // centered in the middle of the canvas. val adjustWidth = bounds.width() / 4 val adjustHeight = bounds.height() / 4 - src.bounds.set( + inner.bounds.set( adjustWidth, adjustHeight, bounds.width() - adjustWidth, bounds.height() - adjustHeight) - src.draw(canvas) + inner.draw(canvas) } + // Required drawable overrides. Just forward to the wrapped drawable. + override fun setAlpha(alpha: Int) { - src.alpha = alpha + inner.alpha = alpha } override fun setColorFilter(colorFilter: ColorFilter?) { - src.colorFilter = colorFilter + inner.colorFilter = colorFilter } override fun getOpacity(): Int = PixelFormat.TRANSLUCENT diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 1b5ddfe7f..18fc6ee51 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -37,7 +37,10 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Sort -/** A basic keyer for music data. */ +/** + * A [Keyer] implementation for [Music] data. + * @author Alexander Capehart (OxygenCobalt) + */ class MusicKeyer : Keyer { override fun key(data: Music, options: Options) = if (data is Song) { @@ -49,25 +52,31 @@ class MusicKeyer : Keyer { } /** - * Fetcher that returns the album cover for a given [Album] or [Song], depending on the factory - * used. - * @author OxygenCobalt + * Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. + * Use [SongFactory] or [AlbumFactory] for instantiation. + * @author Alexander Capehart (OxygenCobalt) */ class AlbumCoverFetcher -private constructor(private val context: Context, private val album: Album) : BaseFetcher() { +private constructor(private val context: Context, private val album: Album) : Fetcher { override suspend fun fetch(): FetchResult? = - fetchCover(context, album)?.let { stream -> + Covers.fetch(context, album)?.run { SourceResult( - source = ImageSource(stream.source().buffer(), context), + source = ImageSource(source().buffer(), context), mimeType = null, dataSource = DataSource.DISK) } + /** + * A [Fetcher.Factory] implementation that works with [Song]s. + */ class SongFactory : Fetcher.Factory { override fun create(data: Song, options: Options, imageLoader: ImageLoader) = AlbumCoverFetcher(options.context, data.album) } + /** + * A [Fetcher.Factory] implementation that works with [Album]s. + */ class AlbumFactory : Fetcher.Factory { override fun create(data: Album, options: Options, imageLoader: ImageLoader) = AlbumCoverFetcher(options.context, data) @@ -75,22 +84,25 @@ private constructor(private val context: Context, private val album: Album) : Ba } /** - * Fetcher that fetches the image for an [Artist] - * @author OxygenCobalt + * [Fetcher] for [Artist] images. Use [Factory] for instantiation. + * @author Alexander Capehart (OxygenCobalt) */ class ArtistImageFetcher private constructor( private val context: Context, private val size: Size, private val artist: Artist -) : BaseFetcher() { +) : Fetcher { override suspend fun fetch(): FetchResult? { // Pick the "most prominent" albums (i.e albums with the most songs) to show in the image. val albums = Sort(Sort.Mode.ByCount, false).albums(artist.albums) - val results = albums.mapAtMost(4) { album -> fetchCover(context, album) } - return createMosaic(context, results, size) + val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) } + return Images.createMosaic(context, results, size) } + /** + * [Fetcher.Factory] implementation. + */ class Factory : Fetcher.Factory { override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = ArtistImageFetcher(options.context, options.size, data) @@ -98,18 +110,18 @@ private constructor( } /** - * Fetcher that fetches the image for a [Genre] - * @author OxygenCobalt + * [Fetcher] for [Genre] images. Use [Factory] for instantiation. + * @author Alexander Capehart (OxygenCobalt) */ class GenreImageFetcher private constructor( private val context: Context, private val size: Size, private val genre: Genre -) : BaseFetcher() { +) : Fetcher { override suspend fun fetch(): FetchResult? { - val results = genre.albums.mapAtMost(4) { fetchCover(context, it) } - return createMosaic(context, results, size) + val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, it) } + return Images.createMosaic(context, results, size) } class Factory : Fetcher.Factory { @@ -119,10 +131,14 @@ private constructor( } /** - * Map at most [n] items from a collection. [transform] is called for each item that is eligible. If - * null is returned, then that item will be skipped. + * Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be + * transformed into [R]. + * @param n The maximum amount of items to map. + * @param transform The function that transforms data [T] from the original list into + * data [R] in the new list. Can return null if the [T] cannot be transformed into an [R]. + * @return A new list of at most N non-null [R] items. */ -private inline fun Collection.mapAtMost( +private inline fun Collection.mapAtMostNotNull( n: Int, transform: (T) -> R? ): List { @@ -130,11 +146,12 @@ private inline fun Collection.mapAtMost( val out = mutableListOf() for (item in this) { - if (out.size < until) { - transform(item)?.let(out::add) - } else { + if (out.size >= until) { break } + + // Still have more data we can transform. + transform(item)?.let(out::add) } return out diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/BaseFetcher.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt similarity index 51% rename from app/src/main/java/org/oxycblt/auxio/image/extractor/BaseFetcher.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt index 09a5f8103..9167ffda4 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/BaseFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt @@ -1,67 +1,35 @@ -/* - * 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.image.extractor import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas import android.media.MediaMetadataRetriever -import android.util.Size as AndroidSize -import androidx.core.graphics.drawable.toDrawable -import coil.decode.DataSource -import coil.decode.ImageSource -import coil.fetch.DrawableResult -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.size.Dimension -import coil.size.Size -import coil.size.pxOrElse import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.metadata.flac.PictureFrame import com.google.android.exoplayer2.metadata.id3.ApicFrame -import java.io.ByteArrayInputStream -import java.io.InputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okio.buffer -import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import java.io.ByteArrayInputStream +import java.io.InputStream /** - * The base implementation for all image fetchers in Auxio. - * @author OxygenCobalt - * - * TODO: File-system derived images [cover.jpg, Artist Images] + * Internal utilities for loading album covers. + * @author Alexander Capehart (OxygenCobalt). */ -abstract class BaseFetcher : Fetcher { +object Covers { /** - * Fetch the [album] cover. This call respects user configuration and has proper redundancy in - * the case that metadata fails to load. + * Fetch an album cover, respecting the current cover configuration. + * @param context [Context] required to load the image. + * @param album [Album] to load the cover from. + * @return An [InputStream] of image data if the cover loading was successful, null if the + * cover loading failed or should not occur. */ - protected suspend fun fetchCover(context: Context, album: Album): InputStream? { + suspend fun fetch(context: Context, album: Album): InputStream? { val settings = Settings(context) return try { @@ -76,18 +44,28 @@ abstract class BaseFetcher : Fetcher { } } + /** + * Load an [Album] cover directly from one of it's Song files. This attempts + * the following in order: + * - [MediaMetadataRetriever], as it has the best support and speed. + * - ExoPlayer's [MetadataRetriever], as some devices (notably Samsung) can have broken + * [MediaMetadataRetriever] implementations. + * - MediaStore, as a last-ditch fallback if the format is really obscure. + * + * @param context [Context] required to load the image. + * @param album [Album] to load the cover from. + * @return An [InputStream] of image data if the cover loading was successful, null otherwise. + */ private suspend fun fetchQualityCovers(context: Context, album: Album) = - // Loading quality covers basically means to parse the file metadata ourselves - // and then extract the cover. - - // First try MediaMetadataRetriever. We will always do this first, as it supports - // a variety of formats, has multiple levels of fault tolerance, and is pretty fast - // for a manual parser. - // However, this does not seem to work on some devices (Notably Samsung), so we - // have to have redundancy. fetchAospMetadataCovers(context, album) ?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(context, album) + /** + * Loads an album cover with [MediaMetadataRetriever]. + * @param context [Context] required to load the image. + * @param album [Album] to load the cover from. + * @return An [InputStream] of image data if the cover loading was successful, null otherwise. + */ private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? { MediaMetadataRetriever().apply { // This call is time-consuming but it also doesn't seem to hold up the main thread, @@ -101,6 +79,12 @@ abstract class BaseFetcher : Fetcher { } } + /** + * Loads an [Album] cover with ExoPlayer's [MetadataRetriever]. + * @param context [Context] required to load the image. + * @param album [Album] to load the cover from. + * @return An [InputStream] of image data if the cover loading was successful, null otherwise. + */ private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { val uri = album.songs[0].uri val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri)) @@ -167,6 +151,12 @@ abstract class BaseFetcher : Fetcher { return stream } + /** + * Loads an [Album] cover from MediaStore. + * @param context [Context] required to load the image. + * @param album [Album] to load the cover from. + * @return An [InputStream] of image data if the cover loading was successful, null otherwise. + */ @Suppress("BlockingMethodInNonBlockingContext") private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? { val uri = data.coverUri @@ -174,73 +164,4 @@ abstract class BaseFetcher : Fetcher { // 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 - */ - protected suspend fun createMosaic( - context: Context, - streams: List, - size: Size - ): FetchResult? { - if (streams.size < 4) { - return streams.firstOrNull()?.let { stream -> - return SourceResult( - source = ImageSource(stream.source().buffer(), context), - mimeType = null, - dataSource = DataSource.DISK) - } - } - - // Use whatever size coil gives us to create the mosaic, rounding it to even so that we - // get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a - // 512x512 mosaic. - val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) - val mosaicFrameSize = - Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2)) - - val mosaicBitmap = - Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(mosaicBitmap) - - var x = 0 - var y = 0 - - // For each stream, create a bitmap scaled to 1/4th of the mosaics combined size - // and place it on a corner of the canvas. - for (stream in streams) { - if (y == mosaicSize.height) { - break - } - - // Run the bitmap through a transform to make sure it's a square of the desired - // resolution. - val bitmap = - SquareFrameTransform.INSTANCE.transform( - BitmapFactory.decodeStream(stream), mosaicFrameSize) - - canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) - - x += bitmap.width - - if (x == mosaicSize.width) { - x = 0 - y += bitmap.height - } - } - - // It's way easier to map this into a drawable then try to serialize it into an - // BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to - // load low-res mosaics into high-res ImageViews. - return DrawableResult( - drawable = mosaicBitmap.toDrawable(context.resources), - isSampled = true, - dataSource = DataSource.DISK) - } - - private fun Dimension.mosaicSize(): Int { - val size = pxOrElse { 512 } - return if (size.mod(2) > 0) size + 1 else size - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CrossfadeTransitionFactory.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFractory.kt similarity index 92% rename from app/src/main/java/org/oxycblt/auxio/image/extractor/CrossfadeTransitionFactory.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFractory.kt index 6d364a1bd..ac7ff0700 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CrossfadeTransitionFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFractory.kt @@ -28,9 +28,9 @@ import coil.transition.TransitionTarget /** * A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know. * Like they used to. - * @author Coil Team + * @author Coil Team, Alexander Capehart (OxygenCobalt) */ -class CrossfadeTransitionFactory : Transition.Factory { +class ErrorCrossfadeTransitionFractory : Transition.Factory { override fun create(target: TransitionTarget, result: ImageResult): Transition { // Don't animate if the request was fulfilled by the memory cache. if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt new file mode 100644 index 000000000..97cd143f0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt @@ -0,0 +1,97 @@ +package org.oxycblt.auxio.image.extractor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.util.Size as AndroidSize +import androidx.core.graphics.drawable.toDrawable +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.SourceResult +import coil.size.Dimension +import coil.size.Size +import coil.size.pxOrElse +import okio.buffer +import okio.source +import java.io.InputStream + +/** + * Utilities for constructing Artist and Genre images. + * @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid + */ +object Images { + /** + * Create a mosaic image from the given image [InputStream]s. + * Derived from phonograph: https://github.com/kabouzeid/Phonograph + * @param context [Context] required to generate the mosaic. + * @param streams [InputStream]s of image data to create the mosaic out of. + * @param size [Size] of the Mosaic to generate. + */ + suspend fun createMosaic( + context: Context, + streams: List, + size: Size + ): FetchResult? { + if (streams.size < 4) { + return streams.firstOrNull()?.let { stream -> + return SourceResult( + source = ImageSource(stream.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK) + } + } + + // Use whatever size coil gives us to create the mosaic, rounding it to even so that we + // get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a + // 512x512 mosaic. + val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) + val mosaicFrameSize = + Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2)) + + val mosaicBitmap = + Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(mosaicBitmap) + + var x = 0 + var y = 0 + + // For each stream, create a bitmap scaled to 1/4th of the mosaics combined size + // and place it on a corner of the canvas. + for (stream in streams) { + if (y == mosaicSize.height) { + break + } + + // Run the bitmap through a transform to make sure it's a square of the desired + // resolution. + val bitmap = + SquareFrameTransform.INSTANCE.transform( + BitmapFactory.decodeStream(stream), mosaicFrameSize) + + canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) + + x += bitmap.width + + if (x == mosaicSize.width) { + x = 0 + y += bitmap.height + } + } + + // It's way easier to map this into a drawable then try to serialize it into an + // BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to + // load low-res mosaics into high-res ImageViews. + return DrawableResult( + drawable = mosaicBitmap.toDrawable(context.resources), + isSampled = true, + dataSource = DataSource.DISK) + } + + private fun Dimension.mosaicSize(): Int { + val size = pxOrElse { 512 } + return if (size.mod(2) > 0) size + 1 else size + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt index 69ad651c0..36ee8f840 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt @@ -26,7 +26,7 @@ import kotlin.math.min /** * A transformation that performs a center crop-style transformation on an image, however unlike the * actual ScaleType, this isn't affected by any hacks we do with ImageView itself. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class SquareFrameTransform : Transformation { override val cacheKey: String diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt new file mode 100644 index 000000000..7fbea9f31 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -0,0 +1,15 @@ +package org.oxycblt.auxio.list + +import androidx.annotation.StringRes + + +/** + * A marker for something that is a RecyclerView item. Has no functionality on it's own. + */ +interface Item + +/** + * A "header" used for delimiting groups of data. + * @param titleRes The string resource used for the header's title. + */ +data class Header(@StringRes val titleRes: Int) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/list/List.kt b/app/src/main/java/org/oxycblt/auxio/list/List.kt deleted file mode 100644 index 3bf842309..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/List.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.list - -import android.view.View -import androidx.annotation.StringRes - -/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */ -interface Item - -/** A data object used solely for the "Header" UI element. */ -data class Header( - /** The string resource used for the header. */ - @StringRes val string: Int -) : Item - -open class ItemClickCallback(val onClick: (Item) -> Unit) - -open class ItemMenuCallback(onClick: (Item) -> Unit, val onOpenMenu: (Item, View) -> Unit) : - ItemClickCallback(onClick) - -open class ItemSelectCallback( - onClick: (Item) -> Unit, - onOpenMenu: (Item, View) -> Unit, - val onSelect: (Item) -> Unit -) : ItemMenuCallback(onClick, onOpenMenu) diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index 93202e50a..9c0740b14 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -17,7 +17,6 @@ package org.oxycblt.auxio.list -import android.os.Bundle import android.view.MenuItem import android.view.View import androidx.annotation.MenuRes @@ -26,23 +25,20 @@ import androidx.fragment.app.activityViewModels import androidx.viewbinding.ViewBinding import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.selection.SelectionToolbarOverlay -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.shared.MainNavigationAction import org.oxycblt.auxio.shared.NavigationViewModel -import org.oxycblt.auxio.shared.ViewBindingFragment -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast -abstract class ListFragment : ViewBindingFragment() { - protected val selectionModel: SelectionViewModel by activityViewModels() - private var currentMenu: PopupMenu? = null - - protected val playbackModel: PlaybackViewModel by androidActivityViewModels() +/** + * A Fragment containing a selectable list. + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ListFragment : SelectionFragment(), ExtendedListListener { protected val navModel: NavigationViewModel by activityViewModels() + private var currentMenu: PopupMenu? = null override fun onDestroyBinding(binding: VB) { super.onDestroyBinding(binding) @@ -50,57 +46,35 @@ abstract class ListFragment : ViewBindingFragment() { currentMenu = null } - fun setupSelectionToolbar(toolbar: SelectionToolbarOverlay) { - toolbar.apply { - setOnSelectionCancelListener { selectionModel.consume() } - setOnMenuItemClickListener { - handleSelectionMenuItem(it) - true - } - } - } - - /** Handle a media item with a selection. */ - private fun handleSelectionMenuItem(item: MenuItem) { - when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(selectionModel.consume()) - requireContext().showToast(R.string.lng_queue_added) - } - R.id.action_queue_add -> { - playbackModel.addToQueue(selectionModel.consume()) - requireContext().showToast(R.string.lng_queue_added) - } - } - } - /** - * Called when an item is clicked by the user and was not selected by [handleClick]. This can be - * optionally implemented if [handleClick] is used. + * Called when [onClick] is called, but does not result in the item being selected. This + * more or less corresponds to an [onClick] implementation in a non-[ListFragment]. + * @param music The [Music] item that was clicked. */ - open fun onRealClick(music: Music) { - throw NotImplementedError() - } + abstract fun onRealClick(music: Music) - /** Provided implementation of an item click callback that handles selection. */ - protected fun handleClick(item: Item) { + override fun onClick(item: Item) { check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } if (selectionModel.selected.value.isNotEmpty()) { + // Map clicking an item to selecting an item when items are already selected. selectionModel.select(item) } else { + // Delegate to the concrete implementation when we don't select the item. onRealClick(item) } } - /** Provided implementation of an item selection callback. */ - protected fun handleSelect(item: Item) { + override fun onSelect(item: Item) { check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } selectionModel.select(item) } /** - * Opens the given menu in context of [song]. Assumes that the menu is only composed of common - * [Song] options. + * Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and + * closed when the view is destroyed. If a menu is already opened, this call is ignored. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param song The [Song] to create the menu for. */ protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) { logD("Launching new song menu: ${song.rawName}") @@ -134,8 +108,11 @@ abstract class ListFragment : ViewBindingFragment() { } /** - * Opens the given menu in context of [album]. Assumes that the menu is only composed of common - * [Album] options. + * Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and + * closed when the view is destroyed. If a menu is already opened, this call is ignored. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param song The [Artist] to create the menu for. */ protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) { logD("Launching new album menu: ${album.rawName}") @@ -167,8 +144,11 @@ abstract class ListFragment : ViewBindingFragment() { } /** - * Opens the given menu in context of [artist]. Assumes that the menu is only composed of common - * [Artist] options. + * Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and + * closed when the view is destroyed. If a menu is already opened, this call is ignored. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param song The [Artist] to create the menu for. */ protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) { logD("Launching new artist menu: ${artist.rawName}") @@ -197,8 +177,11 @@ abstract class ListFragment : ViewBindingFragment() { } /** - * Opens the given menu in context of [genre]. Assumes that the menu is only composed of common - * [Genre] options. + * Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and + * closed when the view is destroyed. If a menu is already opened, this call is ignored. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param song The [Genre] to create the menu for. */ protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) { logD("Launching new genre menu: ${genre.rawName}") @@ -226,22 +209,31 @@ abstract class ListFragment : ViewBindingFragment() { } } + /** + * Internally create a menu for a [Music] item. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param onMenuItemClick A callback for when a [MenuItem] is selected. + */ private fun openMusicMenuImpl( anchor: View, @MenuRes menuRes: Int, - onClick: (MenuItem) -> Unit + onMenuItemClick: (MenuItem) -> Unit ) { openMenu(anchor, menuRes) { setOnMenuItemClickListener { item -> - onClick(item) + onMenuItemClick(item) true } } } /** - * Open a generic menu with configuration in [block]. If a menu is already opened, then this - * function is a no-op. + * Open a menu. This menu will be managed by the Fragment and closed when the view is + * destroyed. If a menu is already opened, this call is ignored. + * @param anchor The [View] to anchor the menu to. + * @param menuRes The resource of the menu to load. + * @param block A block that is ran within [PopupMenu] that allows further configuration. */ protected fun openMenu(anchor: View, @MenuRes menuRes: Int, block: PopupMenu.() -> Unit) { if (currentMenu != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt new file mode 100644 index 000000000..2b30ca818 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt @@ -0,0 +1,54 @@ +package org.oxycblt.auxio.list + +import android.view.View +import android.widget.Button + +/** + * A basic listener for list interactions. + */ +interface BasicListListener { + /** + * Called when an [Item] in the list is clicked. + * @param item The [Item] that was clicked. + */ + fun onClick(item: Item) +} + +/** + * An extension of [BasicListListener] that enables menu and selection functionality. + */ +interface ExtendedListListener : BasicListListener { + /** + * Called when an [Item] in the list requests that a menu related to it should be opened. + * @param item The [Item] to show a menu for. + * @param anchor The [View] to anchor the menu to. + */ + fun onOpenMenu(item: Item, anchor: View) + + /** + * Called when an [Item] in the list requests that it be selected. + * @param item The [Item] to select. + */ + fun onSelect(item: Item) + + /** + * Binds this instance to a list item. + * @param item The [Item] that this list entry is bound to. + * @param root The root of the list [View]. + * @param menuButton A [Button] that opens a menu. + */ + fun bind(item: Item, root: View, menuButton: Button) { + root.apply { + // Map clicks to the click callback. + setOnClickListener { onClick(item) } + // Map long clicks to the selection callback. + setOnLongClickListener { + onSelect(item) + true + } + } + + // Map the menu button to the menu opening callback. + menuButton.setOnClickListener { onOpenMenu(item, it) } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index 9a64da26a..52adb7c5a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -27,30 +27,50 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.util.systemBarInsetsCompat -/** A [RecyclerView] that enables some extra functionality for Auxio's use-case. */ +/** + * A [RecyclerView] with a few QoL extensions, such as: + * - Automatic edge-to-edge support + * - Adapter-based [SpanSizeLookup] implementation + * - Automatic [setHasFixedSize] setup + * @author Alexander Capehart (OxygenCobalt) + */ open class AuxioRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : RecyclerView(context, attrs, defStyleAttr) { - private val initialPadding = Rect(paddingLeft, paddingTop, paddingRight, paddingBottom) + /** + * An adapter-specific hook to [GridLayoutManager.SpanSizeLookup]. + */ + interface SpanSizeLookup { + /** + * Get if the item at a position takes up the whole width of the [RecyclerView] or not. + * @param position The position of the item. + * @return true if the item is full-width, false otherwise. + */ + fun isItemFullWidth(position: Int): Boolean + } + + // Keep track of the layout-defined bottom padding so we can re-apply it when applying insets. + private val initialPaddingBottom = paddingBottom init { // Prevent children from being clipped by window insets clipToPadding = false + // Auxio's non-dialog RecyclerViews never change their size based on adapter contents, + // so we can enable fixed-size optimizations. setHasFixedSize(true) } final override fun setHasFixedSize(hasFixedSize: Boolean) { + // Prevent a this leak by marking setHasFixedSize as final. super.setHasFixedSize(hasFixedSize) } override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + // Update the RecyclerView's padding such that the bottom insets are applied + // while still preserving bottom padding. updatePadding( - initialPadding.left, - initialPadding.top, - initialPadding.right, - initialPadding.bottom + insets.systemBarInsetsCompat.bottom) - + bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom) return insets } @@ -58,16 +78,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr super.setAdapter(adapter) if (adapter is SpanSizeLookup) { + // This adapter has support for special span sizes, hook it up to the + // GridLayoutManager. val glm = (layoutManager as GridLayoutManager) + val fullWidthSpanCount = glm.spanCount glm.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + // Using the adapter implementation, if the adapter specifies that + // an item is full width, it will take up all of the spans, using a + // single span otherwise. override fun getSpanSize(position: Int) = - if (adapter.isItemFullWidth(position)) glm.spanCount else 1 + if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1 } } } - - interface SpanSizeLookup { - fun isItemFullWidth(position: Int): Boolean - } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt index c7e21ad83..db098b803 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt @@ -31,79 +31,93 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getDimenSize /** - * A RecyclerView that enables something resembling the android:scrollIndicators attribute. Only - * used in dialogs. - * @author OxygenCobalt + * A [RecyclerView] intended for use in Dialogs, adding features such as: + * - NestedScrollView scrollIndicators behavior emulation + * - Dialog-specific [ViewHolder] that automatically resolves certain issues. + * @author Alexander Capehart (OxygenCobalt) */ class DialogRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : RecyclerView(context, attrs, defStyleAttr) { + /** + * A [RecyclerView.ViewHolder] that implements dialog-specific fixes. + */ + abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { + init { + // ViewHolders are not automatically full-width in dialogs, manually resize + // them to be as such. + root.layoutParams = + LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + } + private val topDivider = MaterialDivider(context) private val bottomDivider = MaterialDivider(context) private val spacingMedium = context.getDimenSize(R.dimen.spacing_medium) init { + // Apply top padding to give enough room to the dialog title, assuming that this view + // is at the top of the dialog. updatePadding(top = spacingMedium) + // Disable over-scrolling, the top and bottom dividers have the same purpose. overScrollMode = OVER_SCROLL_NEVER - + // Safer to use the overlay than the actual RecyclerView children. overlay.apply { add(topDivider) add(bottomDivider) } } - override fun onScrolled(dx: Int, dy: Int) { - super.onScrolled(dx, dy) - invalidateDividers() - } - override fun onMeasure(widthSpec: Int, heightSpec: Int) { super.onMeasure(widthSpec, heightSpec) measureDivider(topDivider) measureDivider(bottomDivider) } + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + topDivider.layout(l, spacingMedium, r, spacingMedium + topDivider.measuredHeight) + bottomDivider.layout(l, measuredHeight - bottomDivider.measuredHeight, r, b) + // Make sure we initialize the dividers here before we start drawing. + invalidateDividers() + } + + override fun onScrolled(dx: Int, dy: Int) { + super.onScrolled(dx, dy) + // Scroll event occurred, need to update the dividers. + invalidateDividers() + } + + /** + * Measure a [divider] with the equivalent of match_parent and wrap_content. + * @param divider The divider to measure. + */ private fun measureDivider(divider: MaterialDivider) { val widthMeasureSpec = ViewGroup.getChildMeasureSpec( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), 0, divider.layoutParams.width) - val heightMeasureSpec = ViewGroup.getChildMeasureSpec( MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), 0, divider.layoutParams.height) - divider.measure(widthMeasureSpec, heightMeasureSpec) } - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - super.onLayout(changed, l, t, r, b) - topDivider.layout(l, spacingMedium, r, spacingMedium + topDivider.measuredHeight) - bottomDivider.layout(l, measuredHeight - bottomDivider.measuredHeight, r, b) - invalidateDividers() - } - + /** + * Invalidate the visibility of both dividers. + */ private fun invalidateDividers() { - val manager = layoutManager as LinearLayoutManager - topDivider.isInvisible = manager.findFirstCompletelyVisibleItemPosition() < 1 + val lmm = layoutManager as LinearLayoutManager + // Top divider should only be visible when the first item has gone off-screen. + topDivider.isInvisible = lmm.findFirstCompletelyVisibleItemPosition() < 1 + // Bottom divider should only be visible when the lsat item is completely on-screen. bottomDivider.isInvisible = - manager.findLastCompletelyVisibleItemPosition() == (manager.itemCount - 1) + lmm.findLastCompletelyVisibleItemPosition() == (lmm.itemCount - 1) } } -/** - * ViewHolder that correctly resizes the item to match the parent width, which it is not normally in - * dialogs. - */ -abstract class DialogViewHolder(root: View) : RecyclerView.ViewHolder(root) { - init { - // Actually make the item full-width, which it won't be in dialogs - root.layoutParams = - RecyclerView.LayoutParams( - RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt index 323dbb6be..4195dbfaf 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt @@ -20,59 +20,82 @@ package org.oxycblt.auxio.list.recycler import android.view.View import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW /** - * An adapter capable of highlighting particular viewholders as "Playing". All behavior is handled - * by the adapter, only the implementation ViewHolders need to add code to handle the indicator UI - * itself. - * @author OxygenCobalt + * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. + * @author Alexander Capehart (OxygenCobalt) */ abstract class PlayingIndicatorAdapter : RecyclerView.Adapter() { - private var isPlaying = false - private var currentItem: Item? = null + /** + * A [RecyclerView.ViewHolder] that can display a playing indicator. + */ + abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { + /** + * Update the playing indicator within this [RecyclerView.ViewHolder]. + * @param isActive True if this item is playing, false otherwise. + * @param isPlaying True if playback is ongoing, false if paused. If this + * is true, [isActive] will also be true. + */ + abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) + } - override fun onBindViewHolder(holder: VH, position: Int) = throw UnsupportedOperationException() + // There are actually two states for this adapter: + // This is sub-divided into two states: + // - The currently playing item, which is usually marked as "selected" and becomes accented. + // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is + // displaying + private var currentItem: Item? = null + private var isPlaying = false override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { + if (payloads.isEmpty()) { + // Not updating any indicator-specific things, so delegate to the concrete + // adapter (actually bind the item) + onBindViewHolder(holder, position) + } + + // Only try to update the playing indicator if the ViewHolder supports it if (holder is ViewHolder) { - val item = currentList[position] - val currentItem = currentItem - holder.updatePlayingIndicator(item == currentItem, isPlaying) + holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying) } } + /** + * The current list of the adapter. This is used to update items if the indicator + * state changes. + */ abstract val currentList: List /** * Update the currently playing item in the list. - * @param item The item being played, null if nothing is being played. + * @param item The item currently being played, or null if it is not being played. * @param isPlaying Whether playback is ongoing or paused. */ fun setPlayingItem(item: Item?, isPlaying: Boolean) { var updatedItem = false - if (currentItem != item) { val oldItem = currentItem currentItem = item + // Remove the playing indicator from the old item if (oldItem != null) { val pos = currentList.indexOfFirst { it == oldItem } - if (pos > -1) { - notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED) + notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logW("oldItem was not in adapter data") + logD("oldItem was not in adapter data") } } + // Enable the playing indicator on the new item if (item != null) { val pos = currentList.indexOfFirst { it == item } - if (pos > -1) { - notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED) + notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logW("newItem was not in adapter data") + logD("newItem was not in adapter data") } } @@ -82,24 +105,21 @@ abstract class PlayingIndicatorAdapter : RecyclerV if (this.isPlaying != isPlaying) { this.isPlaying = isPlaying + // We may have already called notifyItemChanged before when checking + // if the item was being played, so in that case we don't need to + // update again here. if (!updatedItem && item != null) { val pos = currentList.indexOfFirst { it == item } - if (pos > -1) { - notifyItemChanged(pos, PAYLOAD_INDICATOR_CHANGED) + notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) } else { - logW("newItem was not in adapter data") + logD("newItem was not in adapter data") } } } } companion object { - val PAYLOAD_INDICATOR_CHANGED = Any() - } - - /** A ViewHolder that can respond to playing ]indicator updates. */ - abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) { - abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) + private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any() } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt index 37c5cdc9f..450ad64c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt @@ -22,28 +22,41 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.music.Music /** - * An adapter that implements selection indicators. - * @author OxygenCobalt + * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of + * items. + * @author Alexander Capehart (OxygenCobalt) */ abstract class SelectionIndicatorAdapter : PlayingIndicatorAdapter() { + /** + * A [PlayingIndicatorAdapter.ViewHolder] that can display a selection indicator. + */ + abstract class ViewHolder(root: View) : PlayingIndicatorAdapter.ViewHolder(root) { + /** + * Update the selection indicator within this [PlayingIndicatorAdapter.ViewHolder]. + * @param isSelected Whether this [PlayingIndicatorAdapter.ViewHolder] is selected. + */ + abstract fun updateSelectionIndicator(isSelected: Boolean) + } + private var selectedItems = setOf() override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { super.onBindViewHolder(holder, position, payloads) - if (holder is ViewHolder) { holder.updateSelectionIndicator(selectedItems.contains(currentList[position])) } } /** - * Update the list of selected [items] within the adapter. + * Update the list of selected items. + * @param items A list of selected [Music]. */ fun setSelectedItems(items: List) { val oldSelectedItems = selectedItems val newSelectedItems = items.toSet() if (newSelectedItems == oldSelectedItems) { + // Nothing to do. return } @@ -53,19 +66,20 @@ abstract class SelectionIndicatorAdapter : // assuming all list items are unique? val item = currentList[i] if (item !is Music) { + // Not applicable. continue } + // Only update items that were added or removed from the list. val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item) val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item) if (added || removed) { - notifyItemChanged(i, PAYLOAD_INDICATOR_CHANGED) + notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED) } } } - /** A ViewHolder that can respond to selection indicator updates. */ - abstract class ViewHolder(root: View) : PlayingIndicatorAdapter.ViewHolder(root) { - abstract fun updateSelectionIndicator(isSelected: Boolean) + companion object { + private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any() } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt index bf18f838c..a5a127173 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt @@ -21,8 +21,10 @@ import androidx.recyclerview.widget.DiffUtil import org.oxycblt.auxio.list.Item /** - * A base [DiffUtil.ItemCallback] that automatically provides an implementation of - * [areContentsTheSame] any object that is derived from [Item]. + * A [DiffUtil.ItemCallback] that automatically implements the [areItemsTheSame] method. + * Use this whenever creating [DiffUtil.ItemCallback] implementations with an [Item] + * subclass. + * @author Alexander Capehart (OxygenCobalt) */ abstract class SimpleItemCallback : DiffUtil.ItemCallback() { final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt index 993a2881a..b666fe052 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt @@ -22,9 +22,10 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView /** - * Like AsyncListDiffer, but synchronous. This may seem like it would be inefficient, but in - * practice Auxio's lists tend to be small enough to the point where this does not matter, and - * situations that would be inefficient are ruled out with [replaceList]. + * A list differ that operates synchronously. This can help resolve some shortcomings with + * AsyncListDiffer, at the cost of performance. + * Derived from Material Files: https://github.com/zhanghai/MaterialFiles + * @author Hai Zhang, Alexander Capehart (OxygenCobalt) */ class SyncListDiffer( adapter: RecyclerView.Adapter<*>, @@ -109,17 +110,26 @@ class SyncListDiffer( result.dispatchUpdatesTo(updateCallback) } - /** Submit a list normally, doing a diff synchronously. Only use this for trivial changes. */ + /** + * Submit a list like AsyncListDiffer. This is exceedingly slow for large diffs, so only + * use it if the changes are trivial. + */ fun submitList(newList: List) { + if (newList == currentList) { + // Nothing to do. + return + } + currentList = newList } /** - * Replace this list with a new list. This is useful for very large list diffs that would - * generally be too chaotic and slow to provide a good UX. + * Replace this list with a new list. This is good for large diffs that are too slow to + * update synchronously, but too chaotic to update asynchronously. */ fun replaceList(newList: List) { if (newList == currentList) { + // Nothing to do. return } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index c3baa4a9e..09ed3f1e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -24,8 +24,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding +import org.oxycblt.auxio.list.ExtendedListListener import org.oxycblt.auxio.list.Header -import org.oxycblt.auxio.list.ItemSelectCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -35,25 +35,21 @@ import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater /** - * The shared ViewHolder for a [Song]. - * @author OxygenCobalt + * A basic [RecyclerView.ViewHolder] that displays a [Song]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) */ class SongViewHolder private constructor(private val binding: ItemSongBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - - fun bind(item: Song, callback: ItemSelectCallback) { - binding.songAlbumCover.bind(item) - binding.songName.text = item.resolveName(binding.context) - binding.songInfo.text = item.resolveArtistContents(binding.context) - - binding.songMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + /** + * Bind new data to this instance. + * @param song The new [Song] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(song: Song, listener: ExtendedListListener) { + listener.bind(song, binding.root, binding.songMenu) + binding.songAlbumCover.bind(song) + binding.songName.text = song.resolveName(binding.context) + binding.songInfo.text = song.resolveArtistContents(binding.context) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -66,11 +62,18 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SONG + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem) @@ -79,25 +82,21 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : } /** - * The Shared ViewHolder for a [Album]. - * @author OxygenCobalt + * A basic [RecyclerView.ViewHolder] that displays a [Album]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) */ class AlbumViewHolder private constructor(private val binding: ItemParentBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - - fun bind(item: Album, callback: ItemSelectCallback) { - binding.parentImage.bind(item) - binding.parentName.text = item.resolveName(binding.context) - binding.parentInfo.text = item.resolveArtistContents(binding.context) - - binding.parentMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + /** + * Bind new data to this instance. + * @param album The new [Album] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(album: Album, listener: ExtendedListListener) { + listener.bind(album, binding.root, binding.parentMenu) + binding.parentImage.bind(album) + binding.parentName.text = album.resolveName(binding.context) + binding.parentInfo.text = album.resolveArtistContents(binding.context) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -110,11 +109,18 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && @@ -125,35 +131,30 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding } /** - * The Shared ViewHolder for a [Artist]. - * @author OxygenCobalt + * A basic [RecyclerView.ViewHolder] that displays a [Artist]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) */ class ArtistViewHolder private constructor(private val binding: ItemParentBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - - fun bind(item: Artist, callback: ItemSelectCallback) { - binding.parentImage.bind(item) - binding.parentName.text = item.resolveName(binding.context) - + /** + * Bind new data to this instance. + * @param artist The new [Artist] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(artist: Artist, listener: ExtendedListListener) { + listener.bind(artist, binding.root, binding.parentMenu) + binding.parentImage.bind(artist) + binding.parentName.text = artist.resolveName(binding.context) binding.parentInfo.text = - if (item.songs.isNotEmpty()) { + if (artist.songs.isNotEmpty()) { binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), - binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)) + binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)) } else { // Artist has no songs, only display an album count. - binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size) + binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size) } - - binding.parentMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -166,11 +167,18 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.rawName == newItem.rawName && @@ -181,29 +189,25 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin } /** - * The Shared ViewHolder for a [Genre]. - * @author OxygenCobalt + * A basic [RecyclerView.ViewHolder] that displays a [Genre]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) */ class GenreViewHolder private constructor(private val binding: ItemParentBinding) : SelectionIndicatorAdapter.ViewHolder(binding.root) { - - fun bind(item: Genre, callback: ItemSelectCallback) { - binding.parentImage.bind(item) - binding.parentName.text = item.resolveName(binding.context) + /** + * Bind new data to this instance. + * @param genre The new [Genre] to bind. + * @param listener An [ExtendedListListener] to bind interactions to. + */ + fun bind(genre: Genre, listener: ExtendedListListener) { + listener.bind(genre, binding.root, binding.parentMenu) + binding.parentImage.bind(genre) + binding.parentName.text = genre.resolveName(binding.context) binding.parentInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_artist_count, item.artists.size), - binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)) - - binding.parentMenu.setOnClickListener { callback.onOpenMenu(item, it) } - binding.root.apply { - setOnClickListener { callback.onClick(item) } - setOnLongClickListener { - callback.onSelect(item) - true - } - } + binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size), + binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size)) } override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { @@ -216,11 +220,18 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size @@ -229,25 +240,35 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding } /** - * The Shared ViewHolder for a [Header]. - * @author OxygenCobalt + * A basic [RecyclerView.ViewHolder] that displays a [Header]. Use [new] to create an instance. + * @author Alexander Capehart (OxygenCobalt) */ class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(item: Header) { - binding.title.text = binding.context.getString(item.string) + /** + * Bind new data to this instance. + * @param header The new [Header] to bind. + */ + fun bind(header: Header) { + binding.title.text = binding.context.getString(header.titleRes) } companion object { + /** Unique ID for this ViewHolder type. */ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_HEADER + /** + * Create a new instance. + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ fun new(parent: View) = HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater)) - val DIFFER = + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = object : SimpleItemCallback
() { override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = - oldItem.string == newItem.string + oldItem.titleRes == newItem.titleRes } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt new file mode 100644 index 000000000..f95598643 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.list.selection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import androidx.appcompat.widget.Toolbar +import androidx.fragment.app.activityViewModels +import androidx.viewbinding.ViewBinding +import org.oxycblt.auxio.R +import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.shared.ViewBindingFragment +import org.oxycblt.auxio.util.androidActivityViewModels +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.showToast + +/** + * A subset of ListFragment that implements aspects of the selection UI. + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class SelectionFragment : + ViewBindingFragment(), Toolbar.OnMenuItemClickListener { + protected val selectionModel: SelectionViewModel by activityViewModels() + protected val playbackModel: PlaybackViewModel by androidActivityViewModels() + + /** + * Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed + * by [SelectionFragment]. + * @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or + * null if there is not one. + */ + open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null + + override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + getSelectionToolbar(binding)?.apply { + // Add cancel and menu item listeners to manage what occurs with the selection. + setOnSelectionCancelListener { selectionModel.consume() } + setOnMenuItemClickListener(this@SelectionFragment) + } + } + + override fun onDestroyBinding(binding: VB) { + super.onDestroyBinding(binding) + getSelectionToolbar(binding)?.setOnMenuItemClickListener(null) + } + + override fun onMenuItemClick(item: MenuItem) = + when (item.itemId) { + R.id.action_selection_play_next -> { + playbackModel.playNext(selectionModel.consume()) + requireContext().showToast(R.string.lng_queue_added) + true + } + R.id.action_selection_queue_add -> { + playbackModel.addToQueue(selectionModel.consume()) + requireContext().showToast(R.string.lng_queue_added) + true + } + else -> false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt index 58520f07c..04433583b 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt @@ -29,16 +29,17 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.util.logD /** - * A wrapper around a Toolbar that enables an overlaid toolbar showing information about an item - * selection. - * @author OxygenCobalt + * A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the + * current selection state. + * @author Alexander Capehart (OxygenCobalt) */ class SelectionToolbarOverlay @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { - + // This will be populated after the inflation completes. private lateinit var innerToolbar: MaterialToolbar + // The selection toolbar will be overlaid over the inner toolbar when shown. private val selectionToolbar = MaterialToolbar(context).apply { setNavigationIcon(R.drawable.ic_close_24) @@ -48,44 +49,65 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr isInvisible = true } } - + // Animator to handle selection visibility animations private var fadeThroughAnimator: ValueAnimator? = null override fun onFinishInflate() { super.onFinishInflate() + // Sanity check: Avoid incorrect views from being included in this layout. check(childCount == 1 && getChildAt(0) is MaterialToolbar) { "SelectionToolbarOverlay Must have only one MaterialToolbar child" } - + // The inner toolbar should be the first child. innerToolbar = getChildAt(0) as MaterialToolbar + // Now layer the selection toolbar on top. addView(selectionToolbar) } + /** + * Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is + * pressed. + * @param listener The OnClickListener to respond to this interaction. + */ fun setOnSelectionCancelListener(listener: OnClickListener) { selectionToolbar.setNavigationOnClickListener(listener) } - fun setOnMenuItemClickListener(listener: OnMenuItemClickListener) { + /** + * Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection + * [MaterialToolbar]. + * @param listener The [OnMenuItemClickListener] to respond to this interaction. + */ + fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) { selectionToolbar.setOnMenuItemClickListener(listener) } /** - * Update the selection amount in the selection Toolbar. This will animate the selection Toolbar - * into focus if there is now a selection to show. + * Update the selection [MaterialToolbar] to reflect the current selection amount. + * @param amount The amount of items that are currently selected. + * @return true if the selection [MaterialToolbar] changes, false otherwise. */ fun updateSelectionAmount(amount: Int): Boolean { logD("Updating selection amount to $amount") return if (amount > 0) { + // Only update the selected amount when it's non-zero to prevent a strange + // title text. selectionToolbar.title = context.getString(R.string.fmt_selected, amount) - animateToolbarVisibility(true) + animateToolbarsVisibility(true) } else { - animateToolbarVisibility(false) + animateToolbarsVisibility(false) } } - private fun animateToolbarVisibility(selectionVisible: Boolean): Boolean { + /** + * Animate the visibility of the inner and selection [MaterialToolbar]s to the given state. + * @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not. + * @return true if the toolbars have changed, false otherwise. + */ + private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean { // TODO: Animate nicer Material Fade transitions using animators (Normal transitions // don't work due to translation) + // Set up the target transitions for both the inner and selection toolbars. val targetInnerAlpha: Float val targetSelectionAlpha: Float val targetDuration: Long @@ -104,6 +126,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr if (innerToolbar.alpha == targetInnerAlpha && selectionToolbar.alpha == targetSelectionAlpha) { + // Nothing to do. return false } @@ -129,6 +152,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return true } + /** + * Update the alpha of the inner and selection [MaterialToolbar]s. + * @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the + * inverse opacity of the selection [MaterialToolbar]. + */ private fun changeToolbarAlpha(innerAlpha: Float) { innerToolbar.apply { alpha = innerAlpha diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index eb44dd84a..13d09f5dd 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -20,36 +20,67 @@ package org.oxycblt.auxio.list.selection import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.util.logD /** - * ViewModel that manages the current selection. - * @author OxygenCobalt + * A [ViewModel] that manages the current selection. + * @author Alexander Capehart (OxygenCobalt) */ -class SelectionViewModel : ViewModel() { +class SelectionViewModel : ViewModel(), MusicStore.Callback { + private val musicStore = MusicStore.getInstance() + private val _selected = MutableStateFlow(listOf()) + /** + * the currently selected items. These are ordered in earliest selected + * and latest selected. + */ val selected: StateFlow> get() = _selected - /** Select a music item. */ - fun select(music: Music) { - val selected = _selected.value.toMutableList() - if (selected.remove(music)) { - logD("Unselecting item $music") - _selected.value = selected - } else { - logD("Selecting item $music") - selected.add(music) - _selected.value = selected + init { + musicStore.addCallback(this) + } + + override fun onLibraryChanged(library: MusicStore.Library?) { + if (library == null) { + return + } + + // Sanitize the selection to remove items that no longer exist and thus + // won't appear in any list. + _selected.value = _selected.value.mapNotNull { + when (it) { + is Song -> library.sanitize(it) + is Album -> library.sanitize(it) + is Artist -> library.sanitize(it) + is Genre -> library.sanitize(it) + } } } - /** Clear and return all selected items. */ - fun consume() = _selected.value.also { _selected.value = listOf() } - override fun onCleared() { super.onCleared() - logD("Cleared") + musicStore.removeCallback(this) } + + /** + * Select a new [Music] item. If this item is already within the selected items, the item will be + * removed. Otherwise, it will be added. + * @param music The [Music] item to select. + */ + fun select(music: Music) { + val selected = _selected.value.toMutableList() + if (!selected.remove(music)) { + selected.add(music) + } + _selected.value = selected + } + + /** + * Consume the current selection. This will clear any items that were selected prior. + * @return The list of selected items before it was cleared. + */ + fun consume() = + _selected.value.also { _selected.value = listOf() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index be019df32..82c5842c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -113,7 +113,7 @@ sealed class Music : Item { * try to do anything interesting with this and just assume it's a black box that can only be * compared, serialized, and deserialized. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @Parcelize class UID @@ -210,7 +210,7 @@ sealed class MusicParent : Music() { /** * A song. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Song constructor(raw: Raw, settings: Settings) : Music() { override val uid = @@ -441,7 +441,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { /** * An album. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Album constructor(raw: Raw, override val songs: List) : MusicParent() { override val uid = @@ -584,7 +584,7 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( /** * An abstract artist. This is derived from both album artist values and artist values in albums and * songs respectively. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Artist constructor(private val raw: Raw, songAlbums: List) : MusicParent() { override val uid = @@ -702,7 +702,7 @@ class Artist constructor(private val raw: Raw, songAlbums: List) : MusicP /** * A genre. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Genre constructor(private val raw: Raw, override val songs: List) : MusicParent() { override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 2312c5276..95acbe2af 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.contentResolverSafe * should also be aware that [Library] may change while they are running, and design their work * accordingly. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MusicStore private constructor() { private val callbacks = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index f71cd82c4..2085c2cac 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -25,7 +25,7 @@ import org.oxycblt.auxio.util.logD /** * A ViewModel representing the current indexing state. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MusicViewModel : ViewModel(), Indexer.Callback { private val indexer = Indexer.getInstance() diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt index e2a6a0425..9f0e13395 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.music.Sort.Mode * Where SORT INT is the corresponding integer value of this specific sort and A is a bit * representing whether this sort is ascending or descending. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ data class Sort(val mode: Mode, val isAscending: Boolean) { fun withAscending(new: Boolean) = Sort(mode, new) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt index 98a448236..111a1e574 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Tags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Tags.kt @@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull * The string representation of a Date is RFC 3339, with granular position depending on the presence * of particular tokens. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Date private constructor(private val tokens: List) : Comparable { init { @@ -189,7 +189,7 @@ class Date private constructor(private val tokens: List) : Comparable * others. Internally, it operates on a reduced version of the MusicBrainz release type * specification. It can be extended if there is demand. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ sealed class ReleaseType { abstract val refinement: Refinement? diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt index 7a432141c..26ceb08fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.requireBackgroundThread * storing "intrinsic" data, as in information derived from the file format and not information from * the media database or file system. The exceptions are the database ID and modification times for * files, as these are required for the cache to function well. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class CacheExtractor(private val context: Context, private val noop: Boolean) { private var cacheMap: Map? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 640b91738..9bae0ef77 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.logD /** * The layer that loads music from the MediaStore database. This is an intermediate step in the * music loading process. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ abstract class MediaStoreExtractor( private val context: Context, @@ -319,7 +319,7 @@ abstract class MediaStoreExtractor( /** * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 21 * onwards to API 29. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) : MediaStoreExtractor(context, cacheDatabase) { @@ -388,7 +388,7 @@ class Api21MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) /** * A [MediaStoreExtractor] that selects directories and builds paths using the modern volume fields * available from API 29 onwards. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) : @@ -443,7 +443,7 @@ open class BaseApi29MediaStoreExtractor(context: Context, cacheDatabase: CacheEx /** * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at * least API 29. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) : @@ -475,7 +475,7 @@ open class Api29MediaStoreExtractor(context: Context, cacheDatabase: CacheExtrac /** * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at * least API 30. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) class Api30MediaStoreExtractor(context: Context, cacheDatabase: CacheExtractor) : diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index 3bb41c6e7..b52806d8d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.logW * * TODO: Fix failing ID3v2 multi-value tests in fork (Implies parsing problem) * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MetadataExtractor( private val context: Context, @@ -113,7 +113,7 @@ class MetadataExtractor( /** * Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get]. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Task(context: Context, private val raw: Song.Raw) { private val future = diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt index e57832c9b..f1f720e4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt @@ -21,14 +21,14 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding -import org.oxycblt.auxio.list.ItemClickCallback -import org.oxycblt.auxio.list.recycler.DialogViewHolder +import org.oxycblt.auxio.list.BasicListListener +import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** The adapter that displays a list of artist choices in the picker UI. */ -class ArtistChoiceAdapter(private val callback: ItemClickCallback) : +class ArtistChoiceAdapter(private val listener: BasicListListener) : RecyclerView.Adapter() { private var artists = listOf() @@ -38,7 +38,7 @@ class ArtistChoiceAdapter(private val callback: ItemClickCallback) : ArtistChoiceViewHolder.new(parent) override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) = - holder.bind(artists[position], callback) + holder.bind(artists[position], listener) fun submitList(newArtists: List) { if (newArtists != artists) { @@ -54,11 +54,11 @@ class ArtistChoiceAdapter(private val callback: ItemClickCallback) : * constraints. */ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : - DialogViewHolder(binding.root) { - fun bind(artist: Artist, callback: ItemClickCallback) { + DialogRecyclerView.ViewHolder(binding.root) { + fun bind(artist: Artist, listener: BasicListListener) { binding.pickerImage.bind(artist) binding.pickerName.text = artist.resolveName(binding.context) - binding.root.setOnClickListener { callback.onClick(artist) } + binding.root.setOnClickListener { listener.onClick(artist) } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt index 734dce3af..327252a5b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.shared.NavigationViewModel /** * The [ArtistPickerDialog] for ambiguous artist navigation operations. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class ArtistNavigationPickerDialog : ArtistPickerDialog() { private val navModel: NavigationViewModel by activityViewModels() @@ -39,8 +39,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() { super.onBindingCreated(binding, savedInstanceState) } - override fun onChoiceConfirmed(item: Item) { - super.onChoiceConfirmed(item) + override fun onClick(item: Item) { + super.onClick(item) check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } navModel.exploreNavigateTo(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt index 83917a8de..4af1cd78a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt @@ -24,15 +24,15 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding +import org.oxycblt.auxio.list.BasicListListener import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.ItemClickCallback import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.shared.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately -abstract class ArtistPickerDialog : ViewBindingDialogFragment() { +abstract class ArtistPickerDialog : ViewBindingDialogFragment(), BasicListListener { protected val pickerModel: MusicPickerViewModel by viewModels() - private val artistAdapter = ArtistChoiceAdapter(ItemClickCallback(::onChoiceConfirmed)) + private val artistAdapter = ArtistChoiceAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = DialogMusicPickerBinding.inflate(inflater) @@ -57,8 +57,7 @@ abstract class ArtistPickerDialog : ViewBindingDialogFragment playbackModel.playFromArtist(song, item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirAdapter.kt index c4f0c3346..98653db02 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirAdapter.kt @@ -21,13 +21,13 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.ItemMusicDirBinding -import org.oxycblt.auxio.list.recycler.DialogViewHolder +import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** * Adapter that shows the list of music folder and their "Clear" button. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter() { private val _dirs = mutableListOf() @@ -69,7 +69,7 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter(), MusicDirAdapter.Listener { diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt index 8626e0aac..45008bced 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt @@ -96,7 +96,7 @@ class Directory private constructor(val volume: StorageVolume, val relativePath: /** * Represents a mime type as it is loaded by Auxio. [fromExtension] is based on the file extension * should always exist, while [fromFormat] is based on the file itself and may not be available. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ data class MimeType(val fromExtension: String, val fromFormat: String?) { fun resolveName(context: Context): String { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 28c6cd51d..55069af12 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -63,7 +63,7 @@ import org.oxycblt.auxio.util.logW * like a job for [MusicStore] but in practice is only really leveraged by the components that * directly work with music loading, making such redundant. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Indexer { private var lastResponse: Response? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 2fa9dacaa..361bd3211 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.logD * You could probably do the same using WorkManager and the GooberQueue library or whatever, but the * boilerplate you skip is not worth the insanity of androidx. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class IndexerService : Service(), Indexer.Controller, Settings.Callback { private val indexer = Indexer.getInstance() 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 bd1278866..0c06fd7f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.getColorCompat /** * A fragment showing the current playback state in a compact manner. Used as the bar for the * playback sheet. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PlaybackBarFragment : ViewBindingFragment() { private val playbackModel: PlaybackViewModel by androidActivityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt index 1169a69b1..d08f09860 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBottomSheetBehavior.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.getDimen /** * The coordinator layout behavior used for the playback sheet, hacking in the many fixes required * to make bottom sheets like this work. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PlaybackBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : AuxioBottomSheetBehavior(context, attributeSet) { 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 d0a756087..0e79c70c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [Fragment] that displays more information about the song, along with more media controls. * Instantiation is done by the navigation component, **do not instantiate this fragment manually.** - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) * * TODO: Make seek thumb grow when selected */ 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 3289428f2..7b6ecff37 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -39,7 +39,7 @@ import org.oxycblt.auxio.util.application * **PLEASE Use this instead of [PlaybackStateManager], UIs are extremely volatile and this provides * an interface that properly sanitizes input and abstracts functions unlike the master class.** * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) * * TODO: Queue additions without a song should map to playing selected */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 02ed6f96e..a65d67b30 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.inflater class QueueAdapter(private val listener: QueueItemListener) : RecyclerView.Adapter() { - private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFFER) + private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK) private var currentIndex = 0 private var isPlaying = false @@ -173,6 +173,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fun new(parent: View) = QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) - val DIFFER = SongViewHolder.DIFFER + val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt index cc615ff0c..a73eaf2da 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueBottomSheetBehavior.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat /** * The bottom sheet behavior designed for the queue in particular. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class QueueBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : AuxioBottomSheetBehavior(context, attributeSet) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 728fe2d0d..8edcae373 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.logD * A highly customized [ItemTouchHelper.Callback] that handles the queue system while basically * rebuilding most the "Material-y" aspects of an editable list because Google's implementations are * hot garbage. This shouldn't have *too many* UI bugs. I hope. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { private var shouldLift = true 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 26c0b2146..b204ef8ac 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 @@ -35,7 +35,7 @@ import org.oxycblt.auxio.util.logD /** * A [Fragment] that shows the queue and enables editing as well. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class QueueFragment : ViewBindingFragment(), QueueItemListener { private val queueModel: QueueViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 6680f33c6..7fb00ac88 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager /** * Class enabling more advanced queue list functionality and queue editing. TODO: Allow editing * previous parts of the queue - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class QueueViewModel : ViewModel(), PlaybackStateManager.Callback { private val playbackManager = PlaybackStateManager.getInstance() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index f5499bf96..8b9cd83ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.context /** * The dialog for customizing the ReplayGain pre-amp values. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 003fc4543..07f9b45c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * Note that you must still give it a [Metadata] instance for it to function, which should be done * when the active track changes. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) * * TODO: Convert to a low-level audio processor capable of handling any kind of PCM data. */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index eae663a11..d70637beb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.requireBackgroundThread /** * A SQLite database for managing the persistent playback state and queue. Yes. I know Room exists. * But that would needlessly bloat my app and has crippling bugs. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PlaybackStateDatabase private constructor(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index d11b67525..bc42c06ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -49,7 +49,7 @@ import org.oxycblt.auxio.util.logW * itself should instead operate as a [InternalPlayer]. * * All access should be done with [PlaybackStateManager.getInstance]. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PlaybackStateManager private constructor() { private val musicStore = MusicStore.getInstance() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/RepeatMode.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/RepeatMode.kt index 6592ae963..318ff3f31 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/RepeatMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/RepeatMode.kt @@ -22,7 +22,7 @@ import org.oxycblt.auxio.R /** * Enum that determines the playback repeat mode. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ enum class RepeatMode { NONE, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 66294fe69..e79e13b96 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.logD * Auxio does not directly rely on MediaSession, as it is extremely poorly designed. We instead just * mirror the playback state into the media session. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class MediaSessionComponent(private val context: Context, private val callback: Callback) : MediaSessionCompat.Callback(), PlaybackStateManager.Callback, Settings.Callback { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt index 12e624822..253b55bc5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt @@ -37,7 +37,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent * The unified notification for [PlaybackService]. Due to the nature of how this notification is * used, it is *not self-sufficient*. Updates have to be delivered manually, as to prevent state * inconsistency derived from callback order. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @SuppressLint("RestrictedApi") class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) : 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 8c66d679a..48e5d1154 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 @@ -75,7 +75,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider * * TODO: Android Auto * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class PlaybackService : Service(), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt index 0518ace78..029c41e9f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R /** * A [MaterialButton] that automatically morphs from a circle to a squircle shape appearance when it * is activated. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class AnimatedMaterialButton @JvmOverloads diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/ForcedLTRFrameLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/ForcedLTRFrameLayout.kt index 9fb05e94c..04413c594 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/ForcedLTRFrameLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/ForcedLTRFrameLayout.kt @@ -33,7 +33,7 @@ import android.widget.FrameLayout * * This layout can only contain one child. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ open class ForcedLTRFrameLayout @JvmOverloads diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/StyledSeekBar.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/StyledSeekBar.kt index 895fa38c1..88fe3e681 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/StyledSeekBar.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/StyledSeekBar.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.logD * A wrapper around [Slider] that shows not only position and duration values, but also hacks in * bounds checking to avoid app crashes if bad position input comes in. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class StyledSeekBar @JvmOverloads diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index e5de315d9..e8c802a99 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -27,9 +27,9 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song -class SearchAdapter(private val callback: ItemSelectCallback) : +class SearchAdapter(private val listener: ExtendedListListener) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - private val differ = AsyncListDiffer(this, DIFFER) + private val differ = AsyncListDiffer(this, DIFF_CALLBACK) override fun getItemCount() = differ.currentList.size @@ -53,21 +53,13 @@ class SearchAdapter(private val callback: ItemSelectCallback) : else -> error("Invalid item type $viewType") } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List - ) { - super.onBindViewHolder(holder, position, payloads) - - if (payloads.isEmpty()) { - when (val item = differ.currentList[position]) { - is Song -> (holder as SongViewHolder).bind(item, callback) - is Album -> (holder as AlbumViewHolder).bind(item, callback) - is Artist -> (holder as ArtistViewHolder).bind(item, callback) - is Genre -> (holder as GenreViewHolder).bind(item, callback) - is Header -> (holder as HeaderViewHolder).bind(item) - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = differ.currentList[position]) { + is Song -> (holder as SongViewHolder).bind(item, listener) + is Album -> (holder as AlbumViewHolder).bind(item, listener) + is Artist -> (holder as ArtistViewHolder).bind(item, listener) + is Genre -> (holder as GenreViewHolder).bind(item, listener) + is Header -> (holder as HeaderViewHolder).bind(item) } } @@ -76,23 +68,25 @@ class SearchAdapter(private val callback: ItemSelectCallback) : override val currentList: List get() = differ.currentList - fun submitList(list: List, callback: () -> Unit) = differ.submitList(list, callback) + fun submitList(list: List, callback: () -> Unit) { + differ.submitList(list, callback) + } companion object { - private val DIFFER = + private val DIFF_CALLBACK = object : SimpleItemCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item) = when { oldItem is Song && newItem is Song -> - SongViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Album && newItem is Album -> - AlbumViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + AlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Artist && newItem is Artist -> - ArtistViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> - GenreViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFFER.areContentsTheSame(oldItem, newItem) + HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index a3d145bc2..662c5425e 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -31,8 +31,8 @@ import com.google.android.material.transition.MaterialSharedAxis import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.ItemSelectCallback import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.selection.SelectionToolbarOverlay import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -46,16 +46,13 @@ import org.oxycblt.auxio.util.* /** * A [Fragment] that allows for the searching of the entire music library. TODO: Minor rework with * better keyboard logic, recycler updating, and chips - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class SearchFragment : ListFragment() { - // SearchViewModel is only scoped to this Fragment private val searchModel: SearchViewModel by androidViewModels() - private val searchAdapter = - SearchAdapter(ItemSelectCallback(::handleClick, ::handleOpenMenu, ::handleSelect)) - private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } + private val searchAdapter = SearchAdapter(this) private val imm: InputMethodManager by lifecycleObject { binding -> binding.context.getSystemServiceCompat(InputMethodManager::class) } @@ -72,8 +69,11 @@ class SearchFragment : ListFragment() { override fun onCreateBinding(inflater: LayoutInflater) = FragmentSearchBinding.inflate(inflater) + override fun getSelectionToolbar(binding: FragmentSearchBinding) = + binding.searchSelectionToolbar + override fun onBindingCreated(binding: FragmentSearchBinding, savedInstanceState: Bundle?) { - setupSelectionToolbar(binding.searchSelectionToolbar) + super.onBindingCreated(binding, savedInstanceState) binding.searchToolbar.apply { val itemIdToSelect = @@ -87,11 +87,13 @@ class SearchFragment : ListFragment() { menu.findItem(itemIdToSelect).isChecked = true - setNavigationOnClickListener { handleSearchNavigateUp() } - setOnMenuItemClickListener { - handleSearchMenuItem(it) - true + setNavigationOnClickListener { + // Drop keyboard as it's no longer needed + imm.hide() + findNavController().navigateUp() } + + setOnMenuItemClickListener(this@SearchFragment) } binding.searchEditText.apply { @@ -112,45 +114,48 @@ class SearchFragment : ListFragment() { // --- VIEWMODEL SETUP --- collectImmediately(searchModel.searchResults, ::updateResults) - collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(navModel.exploreNavigationItem, ::handleNavigation) collectImmediately(selectionModel.selected, ::updateSelection) } override fun onDestroyBinding(binding: FragmentSearchBinding) { + super.onDestroyBinding(binding) + binding.searchToolbar.setOnMenuItemClickListener(null) binding.searchRecycler.adapter = null } + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + // Ignore junk sub-menu click events + if (item.itemId != R.id.submenu_filtering) { + // Is a change in filter mode and not just a junk submenu click, update + // the filtering within SearchViewModel. + searchModel.updateFilterModeWithId(item.itemId) + return true + } + + return false + } + override fun onRealClick(music: Music) { when (music) { is Song -> - when (settings.libPlaybackMode) { + when (val mode = Settings(requireContext()).libPlaybackMode) { MusicMode.SONGS -> playbackModel.playFromAll(music) MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - else -> error("Unexpected playback mode: ${settings.libPlaybackMode}") + else -> error("Unexpected playback mode: ${mode}") } is MusicParent -> navModel.exploreNavigateTo(music) } } - private fun handleSearchNavigateUp() { - // Drop keyboard as it's no longer needed - imm.hide() - findNavController().navigateUp() - } - - private fun handleSearchMenuItem(item: MenuItem) { - // Ignore junk sub-menu click events - if (item.itemId != R.id.submenu_filtering) { - searchModel.updateFilterModeWithId(item.itemId) - } - } - - private fun handleOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Item, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 44ea6c3b4..682314a66 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -46,7 +46,7 @@ import org.oxycblt.auxio.util.logD /** * The [ViewModel] for search functionality. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class SearchViewModel(application: Application) : AndroidViewModel(application), MusicStore.Callback { 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 835fee212..e4945458c 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat /** * A [BottomSheetDialogFragment] that shows Auxio's about screen. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class AboutFragment : ViewBindingFragment() { private val musicModel: MusicViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 8c3ba43c0..7f88155fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -46,7 +46,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * settings that Auxio uses. Mutability is determined by use, as some values are written by * PreferenceManager and others are written by Auxio's code. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Settings(private val context: Context, private val callback: Callback? = null) : SharedPreferences.OnSharedPreferenceChangeListener { 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 5e26640c2..fb9f84299 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.shared.ViewBindingFragment /** * A container [Fragment] for the settings menu. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class SettingsFragment : ViewBindingFragment() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/settings/accent/Accent.kt index 14c43fdb8..14b982f4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/accent/Accent.kt @@ -113,7 +113,7 @@ private val ACCENT_PRIMARY_COLORS = * @property theme The theme resource for this accent * @property blackTheme The black theme resource for this accent * @property primary The primary color resource for this accent - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Accent private constructor(val index: Int) : Item { val name: Int diff --git a/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentAdapter.kt index d699afa92..a0fb2eae9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentAdapter.kt @@ -23,16 +23,16 @@ import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemAccentBinding -import org.oxycblt.auxio.list.ItemClickCallback +import org.oxycblt.auxio.list.BasicListListener import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.inflater /** * An adapter that displays the accent palette. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ -class AccentAdapter(private val callback: ItemClickCallback) : +class AccentAdapter(private val listener: BasicListListener) : RecyclerView.Adapter() { var selectedAccent: Accent? = null private set @@ -52,7 +52,7 @@ class AccentAdapter(private val callback: ItemClickCallback) : val item = Accent.from(position) if (payloads.isEmpty()) { - holder.bind(item, callback) + holder.bind(item, listener) } holder.setSelected(item == selectedAccent) @@ -73,14 +73,14 @@ class AccentAdapter(private val callback: ItemClickCallback) : class AccentViewHolder private constructor(private val binding: ItemAccentBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: Accent, callback: ItemClickCallback) { + fun bind(item: Accent, listener: BasicListListener) { setSelected(false) binding.accent.apply { backgroundTintList = context.getColorCompat(item.primary) contentDescription = context.getString(item.name) TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener { callback.onClick(item) } + setOnClickListener { listener.onClick(item) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentCustomizeDialog.kt index 7ab01c989..1ce47d273 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/accent/AccentCustomizeDialog.kt @@ -23,8 +23,8 @@ 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.list.BasicListListener import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.ItemClickCallback import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.shared.ViewBindingDialogFragment import org.oxycblt.auxio.util.context @@ -33,10 +33,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull /** * Dialog responsible for showing the list of accents to select. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ -class AccentCustomizeDialog : ViewBindingDialogFragment() { - private var accentAdapter = AccentAdapter(ItemClickCallback(::handleClick)) +class AccentCustomizeDialog : ViewBindingDialogFragment(), BasicListListener { + private var accentAdapter = AccentAdapter(this) private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) @@ -75,7 +75,7 @@ class AccentCustomizeDialog : ViewBindingDialogFragment() { binding.accentRecycler.adapter = null } - private fun handleClick(item: Item) { + override fun onClick(item: Item) { check(item is Accent) { "Unexpected datatype: ${item::class.java}" } accentAdapter.setSelectedAccent(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt index 7176b3477..ed05fee31 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt @@ -29,8 +29,15 @@ import androidx.preference.PreferenceViewHolder import java.lang.reflect.Field import org.oxycblt.auxio.R import org.oxycblt.auxio.util.lazyReflectedField -import org.oxycblt.auxio.util.logD +/** + * @brief An implementation of the built-in list preference, backed with integers. + * + * This is not implemented automatically, so a preference screen must override it's dialog opening + * code in order to handle the preference. + * + * @author Alexander Capehart (OxygenCobalt) + */ class IntListPreference @JvmOverloads constructor( @@ -49,22 +56,19 @@ constructor( private val defValue: Int get() = PREFERENCE_DEFAULT_VALUE_FIELD.get(this) as Int - override fun onDependencyChanged(dependency: Preference, disableDependent: Boolean) { - super.onDependencyChanged(dependency, disableDependent) - logD("dependency changed: $dependency") - } - init { val prefAttrs = context.obtainStyledAttributes( attrs, R.styleable.IntListPreference, defStyleAttr, defStyleRes) + // Can't piggy-back on ListPreference, we have to instead define our own + // attributes for entires/values. entries = prefAttrs.getTextArrayOrThrow(R.styleable.IntListPreference_entries) - values = context.resources.getIntArray( prefAttrs.getResourceIdOrThrow(R.styleable.IntListPreference_entryValues)) + // Additional values: offValue defines an "off" position val offValueId = prefAttrs.getResourceId(R.styleable.IntListPreference_offValue, -1) if (offValueId > -1) { offValue = context.resources.getInteger(offValueId) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt index 02baa6868..dff2bd991 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt @@ -23,6 +23,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R +/** + * @brief The companion dialog to [IntListPreference]. + * @author Alexander Capehart (OxygenCobalt) + */ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() { private val listPreference: IntListPreference get() = (preference as IntListPreference) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt index addfec454..f97ed0c74 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat /** * The actual fragment containing the settings menu. Inherits [PreferenceFragmentCompat]. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ @Suppress("UNUSED") class PreferenceFragment : PreferenceFragmentCompat() { diff --git a/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt index 9c3eb3cb1..bbb3157ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/AuxioAppBarLayout.kt @@ -36,6 +36,10 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior * * **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what * scrolling view to use. Failure to specify this will result in the layout not working. + * + * Derived from Material Files: https://github.com/zhanghai/MaterialFiles + * + * @author Hai Zhang, Alexander Capehart (OxygenCobalt) */ open class AuxioAppBarLayout @JvmOverloads @@ -99,10 +103,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr return scrollingChild } - /** - * Hack to prevent RecyclerView jumping when the appbar expands. Adapted from Material Files: - * https://github.com/zhanghai/MaterialFiles/blob/master/app/src/main/java/me/zhanghai/android/files/ui/AppBarLayoutExpandHackListener.kt - */ + /** Hack to prevent RecyclerView jumping when the appbar expands. */ private class ExpansionHackListener(private val recycler: RecyclerView) : OnOffsetChangedListener { private val offsetAnimationMaxEndTime = (AnimationUtils.currentAnimationTimeMillis() + 600) diff --git a/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt index 04af49bde..dceb4423f 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/AuxioBottomSheetBehavior.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.systemGestureInsetsCompat /** * Implements a reasonable enough skeleton around BottomSheetBehavior (Excluding auxio extensions in * the vendored code because of course I have to) for normal use without absurd bugs. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ abstract class AuxioBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : NeoBottomSheetBehavior(context, attributeSet) { diff --git a/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt index a0b1e06f8..290c40111 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/BottomSheetContentBehavior.kt @@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * 3. Touch events. Bottom sheets must always intercept touches in their bounds, or they will click * the now overlapping content view that is only inset and not moved out of the way.. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class BottomSheetContentBehavior(context: Context, attributeSet: AttributeSet?) : CoordinatorLayout.Behavior(context, attributeSet) { diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt index 6ccc3cdd4..88addebcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ForegroundManager.kt @@ -23,7 +23,7 @@ import org.oxycblt.auxio.util.logD /** * Wrapper to create consistent behavior regarding a service's foreground state. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class ForegroundManager(private val service: Service) { private var isForeground = false diff --git a/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt index 0c8200cad..006d7aae0 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/NavigationViewModel.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.util.logD /** * A ViewModel that handles complicated navigation situations. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class NavigationViewModel : ViewModel() { private val _mainNavigationAction = MutableStateFlow(null) diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt b/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt index ae04beb5a..930ba0086 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ServiceNotification.kt @@ -26,7 +26,7 @@ import androidx.core.app.NotificationManagerCompat /** * Wrapper around [NotificationCompat.Builder] that automates parts of the notification setup, under * the assumption that the notification will be used in a service. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ abstract class ServiceNotification(context: Context, info: ChannelInfo) : NotificationCompat.Builder(context, info.id) { diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt index 45c281135..e4837c7b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingDialogFragment.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull /** * A dialog fragment enabling ViewBinding inflation and usage across the dialog fragment lifecycle. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingDialogFragment : DialogFragment() { private var _binding: VB? = null @@ -74,7 +74,7 @@ abstract class ViewBindingDialogFragment : DialogFragment() { "right now, but instead it was ${lifecycle.currentState}" } } - + // TODO: Phase this out fun lifecycleObject(create: (VB) -> T): ReadOnlyProperty { lifecycleObjects.add(LifecycleObject(null, create)) diff --git a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt index 552a75521..401d3c0a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/shared/ViewBindingFragment.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull /** * A fragment enabling ViewBinding inflation and usage across the fragment lifecycle. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingFragment : Fragment() { private var _binding: VB? = null diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index 4ff681e6f..bb32dc891 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -33,6 +33,7 @@ import androidx.annotation.ColorRes import androidx.annotation.DimenRes import androidx.annotation.Dimension import androidx.annotation.DrawableRes +import androidx.annotation.IntegerRes import androidx.annotation.PluralsRes import androidx.annotation.Px import androidx.annotation.StringRes @@ -66,13 +67,20 @@ val Context.contentResolverSafe: ContentResolver get() = applicationContext.contentResolver /** - * Convenience method for getting a plural. - * @param pluralsRes Resource for the plural + * @brief Convenience method for getting a plural. + * @param pluralRes Resource ID for the plural * @param value Int value for the plural. * @return The formatted string requested */ -fun Context.getPlural(@PluralsRes pluralsRes: Int, value: Int) = - resources.getQuantityString(pluralsRes, value, value) +fun Context.getPlural(@PluralsRes pluralRes: Int, value: Int) = + resources.getQuantityString(pluralRes, value, value) + +/** + * @brief Convenience method for obtaining an integer resource + * @param integerRes Resource ID for the integer + * @return The integer resource requested + */ +fun Context.getInteger(@IntegerRes integerRes: Int) = resources.getInteger(integerRes) /** * Convenience method for getting a [ColorStateList] resource safely. diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index bb48cceec..ae403a004 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -40,7 +40,7 @@ import org.oxycblt.auxio.util.logD * widget state based off of that. This cannot be rolled into [WidgetProvider] directly, as it may * result in memory leaks if [PlaybackStateManager]/[Settings] gets created and bound to without * being released. - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class WidgetComponent(private val context: Context) : PlaybackStateManager.Callback, Settings.Callback { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 928c924d1..aa47c9bde 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -41,7 +41,7 @@ import org.oxycblt.auxio.util.logW * * For more specific details about these sub-widgets, see Forms.kt. * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class WidgetProvider : AppWidgetProvider() { /* diff --git a/app/src/main/res/layout/item_tab.xml b/app/src/main/res/layout/item_tab.xml index 20896521f..b41bf68cf 100644 --- a/app/src/main/res/layout/item_tab.xml +++ b/app/src/main/res/layout/item_tab.xml @@ -12,7 +12,7 @@ android:paddingBottom="@dimen/spacing_tiny"> diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index b189acd93..6ebe9d949 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -5,6 +5,7 @@