all: redocument project, part 1
Redocument the detail, home, image, and list modules. Much of this project's documentation has drifted from actual functionality, and newer code is pretty sparely documented. This commit seeks to rectify that by redocumenting every source file in this project.
This commit is contained in:
parent
813daed644
commit
4773a84741
117 changed files with 2906 additions and 1835 deletions
|
@ -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<V extends View> extends CoordinatorLayout.Behavior<V> {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
|
||||
|
|
|
@ -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<FragmentDetailBinding>() {
|
||||
class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), 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<FragmentDetailBinding>() {
|
|||
|
||||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSelection(selected: List<Music>) {
|
||||
/**
|
||||
* Update the current item selection.
|
||||
* @param selected The list of selected items.
|
||||
*/
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<FragmentDetailBinding>() {
|
||||
class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), 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<FragmentDetailBinding>() {
|
|||
|
||||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
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
|
||||
|
||||
if (parent is Album) {
|
||||
item = parent
|
||||
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 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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSelection(selected: List<Music>) {
|
||||
/**
|
||||
* Update the current item selection.
|
||||
* @param selected The list of selected items.
|
||||
*/
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
|
|
38
app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
Normal file
38
app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
Normal file
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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<Toolbar>(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<RecyclerView>(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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<DetailSong?>(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<DetailSong?>
|
||||
get() = _currentSong
|
||||
|
||||
private var currentSongJob: Job? = null
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
/**
|
||||
* The current [Album] that should be displayed in the [Album] detail view. Null if there
|
||||
* is no [Album].
|
||||
*/
|
||||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumData = MutableStateFlow(listOf<Item>())
|
||||
val albumData: StateFlow<List<Item>>
|
||||
/**
|
||||
* The current list data derived from [currentAlbum], for use in the [Album] detail view.
|
||||
*/
|
||||
val albumList: StateFlow<List<Item>>
|
||||
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<Artist?>(null)
|
||||
/**
|
||||
* The current [Artist] that should be displayed in the [Artist] detail view. Null if there
|
||||
* is no [Artist].
|
||||
*/
|
||||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistData = MutableStateFlow(listOf<Item>())
|
||||
val artistData: StateFlow<List<Item>> = _artistData
|
||||
/**
|
||||
* The current list derived from [currentArtist], for use in the [Artist] detail view.
|
||||
*/
|
||||
val artistList: StateFlow<List<Item>> = _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<Genre?>(null)
|
||||
/**
|
||||
* The current [Genre] that should be displayed in the [Genre] detail view. Null if there
|
||||
* is no [Genre].
|
||||
*/
|
||||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreData = MutableStateFlow(listOf<Item>())
|
||||
val genreData: StateFlow<List<Item>> = _genreData
|
||||
/**
|
||||
* The current list data derived from [currentGenre], for use in the [Genre] detail view.
|
||||
*/
|
||||
val genreList: StateFlow<List<Item>> = _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)
|
||||
}
|
||||
|
||||
fun setSongUid(uid: Music.UID) {
|
||||
if (_currentSong.value?.run { song.uid } == uid) return
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
val song = requireNotNull(library.find<Song>(uid)) { "Invalid song id provided" }
|
||||
generateDetailSong(song)
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
}
|
||||
|
||||
fun clearSong() {
|
||||
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) {
|
||||
// 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<Album>(uid)) { "Invalid album id provided " }
|
||||
_currentAlbum.value = album
|
||||
refreshAlbumData(album)
|
||||
if (_currentAlbum.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
_currentAlbum.value = requireMusic<Album>(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<Artist>(uid)) { "Invalid artist id provided" }
|
||||
_currentArtist.value = artist
|
||||
refreshArtistData(artist)
|
||||
if (_currentArtist.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
_currentArtist.value = requireMusic<Artist>(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<Genre>(uid)) { "Invalid genre id provided" }
|
||||
_currentGenre.value = genre
|
||||
refreshGenreData(genre)
|
||||
if (_currentGenre.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
private fun generateDetailSong(song: Song) {
|
||||
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <T: Music> 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<Item>(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<Item>(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<Item>(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
|
||||
|
|
|
@ -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<FragmentDetailBinding>() {
|
||||
class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), 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<FragmentDetailBinding>() {
|
|||
|
||||
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<FragmentDetailBinding>() {
|
|||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentDetailBinding>() {
|
|||
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<FragmentDetailBinding>() {
|
|||
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<FragmentDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleSelection(selected: List<Music>) {
|
||||
/**
|
||||
* Update the current item selection.
|
||||
* @param selected The list of selected items.
|
||||
*/
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<DialogSongDetailBinding>() {
|
||||
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<DialogSongDetailBinding>() {
|
|||
|
||||
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
|
||||
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))
|
||||
binding.detailFormat.setText(song.info.resolvedMimeType.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))
|
||||
|
||||
if (song.info.bitrateKbps != null) {
|
||||
// Bit rate (if present)
|
||||
if (song.properties.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(
|
||||
getString(R.string.fmt_bitrate, song.info.bitrateKbps))
|
||||
getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
|
||||
} else {
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
}
|
||||
|
||||
if (song.info.sampleRate != null) {
|
||||
// Sample rate (if present)
|
||||
if (song.properties.sampleRateHz != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, song.info.sampleRate))
|
||||
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
|
||||
}
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
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, callback)
|
||||
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
|
||||
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
||||
is Song -> (holder as AlbumSongViewHolder).bind(item, callback)
|
||||
}
|
||||
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<Item>() {
|
||||
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)
|
||||
|
||||
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)
|
||||
// Fall back to DetailAdapter's differ to handle other headers.
|
||||
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Album>() {
|
||||
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<DiscHeader>() {
|
||||
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) {
|
||||
/**
|
||||
* 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 {
|
||||
text = context.getString(R.string.fmt_number, item.track)
|
||||
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)
|
||||
}
|
||||
contentDescription = context.getString(R.string.desc_track_number, song.track)
|
||||
} else {
|
||||
binding.songTrack.apply {
|
||||
// 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<Song>() {
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
|
||||
|
|
|
@ -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<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
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, callback)
|
||||
is Album -> (holder as ArtistAlbumViewHolder).bind(item, callback)
|
||||
is Song -> (holder as ArtistSongViewHolder).bind(item, callback)
|
||||
}
|
||||
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<Item>() {
|
||||
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<Artist>() {
|
||||
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<Album>() {
|
||||
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<Song>() {
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
|
|
|
@ -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<Item>
|
||||
private val callback: Listener,
|
||||
itemCallback: DiffUtil.ItemCallback<Item>
|
||||
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), 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<Any>
|
||||
) {
|
||||
val item = differ.currentList[position]
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
when (item) {
|
||||
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<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
fun submitList(list: List<Item>) {
|
||||
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<Item>) {
|
||||
differ.submitList(newList)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
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<SortHeader>() {
|
||||
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
||||
oldItem.string == newItem.string
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
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)
|
||||
}
|
||||
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<Item>() {
|
||||
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<Genre>() {
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeBinding>() {
|
||||
class HomeFragment :
|
||||
SelectionFragment<FragmentHomeBinding>(), 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<String> by lifecycleObject {
|
||||
|
@ -95,15 +100,14 @@ class HomeFragment : ListFragment<FragmentHomeBinding>() {
|
|||
|
||||
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<FragmentHomeBinding>() {
|
|||
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<FragmentHomeBinding>() {
|
|||
// 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<FragmentHomeBinding>() {
|
|||
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<FragmentHomeBinding>() {
|
|||
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
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
updateSortMenu(tab) { id -> id != R.id.option_sort_album }
|
||||
|
||||
// Set up the mapping between the ViewPager and TabLayout.
|
||||
TabLayoutMediator(binding.homeTabs, binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes)).attach()
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
updateSortMenu(tab) { id ->
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
private fun handleRecreateTabs(recreate: Boolean) {
|
||||
if (recreate) {
|
||||
val binding = requireBinding()
|
||||
|
||||
binding.homePager.apply {
|
||||
currentItem = 0
|
||||
adapter = HomePagerAdapter()
|
||||
}
|
||||
|
||||
setupTabs(binding)
|
||||
|
||||
homeModel.finishRecreateTabs()
|
||||
}
|
||||
// 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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Song>, 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<FragmentHomeBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FragmentHomeBinding>() {
|
|||
findNavController().navigate(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current item selection.
|
||||
* @param selected The list of selected items.
|
||||
*/
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||
|
@ -392,60 +477,63 @@ class HomeFragment : ListFragment<FragmentHomeBinding>() {
|
|||
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<MusicMode>,
|
||||
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 {
|
||||
private val VP_RECYCLER_FIELD: Field by
|
||||
|
|
|
@ -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<Song>())
|
||||
val songs: StateFlow<List<Song>>
|
||||
get() = _songs
|
||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||
/**
|
||||
* A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view.
|
||||
*/
|
||||
val songLists: StateFlow<List<Song>>
|
||||
get() = _songsList
|
||||
|
||||
private val _albums = MutableStateFlow(listOf<Album>())
|
||||
val albums: StateFlow<List<Album>>
|
||||
get() = _albums
|
||||
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
||||
/**
|
||||
* A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view.
|
||||
*/
|
||||
val albumsList: StateFlow<List<Album>>
|
||||
get() = _albumsLists
|
||||
|
||||
private val _artists = MutableStateFlow(listOf<Artist>())
|
||||
val artists: MutableStateFlow<List<Artist>>
|
||||
get() = _artists
|
||||
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
||||
/**
|
||||
* 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<List<Artist>>
|
||||
get() = _artistsList
|
||||
|
||||
private val _genres = MutableStateFlow(listOf<Genre>())
|
||||
val genres: StateFlow<List<Genre>>
|
||||
get() = _genres
|
||||
|
||||
var tabs: List<MusicMode> = visibleTabs
|
||||
private set
|
||||
|
||||
/** Internal getter for getting the visible library tabs */
|
||||
private val visibleTabs: List<MusicMode>
|
||||
get() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
|
||||
private val _currentTab = MutableStateFlow(tabs[0])
|
||||
val currentTab: StateFlow<MusicMode> = _currentTab
|
||||
private val _genresList = MutableStateFlow(listOf<Genre>())
|
||||
/**
|
||||
* A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view.
|
||||
*/
|
||||
val genresList: StateFlow<List<Genre>>
|
||||
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<Boolean> = _shouldRecreateTabs
|
||||
var currentTabModes: List<MusicMode> = getVisibleTabModes()
|
||||
private set
|
||||
|
||||
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
|
||||
/**
|
||||
* The [MusicMode] of the currently shown [Tab].
|
||||
*/
|
||||
val currentTabMode: StateFlow<MusicMode> = _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<Boolean> = _shouldRecreate
|
||||
|
||||
private val _isFastScrolling = MutableStateFlow(false)
|
||||
/**
|
||||
* A marker for whether the user is fast-scrolling in the home view or not.
|
||||
*/
|
||||
val isFastScrolling: StateFlow<Boolean> = _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<Tab.Visible>().map { it.mode }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<FragmentHomeListBinding>() {
|
||||
class AlbumListFragment : ListFragment<FragmentHomeListBinding>(), 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<FragmentHomeListBinding>() {
|
|||
|
||||
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<FragmentHomeListBinding>() {
|
|||
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<AlbumViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER)
|
||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
@ -147,14 +158,14 @@ class AlbumListFragment : ListFragment<FragmentHomeListBinding>() {
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
AlbumViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int, payloads: List<Any>) {
|
||||
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<Album>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>() {
|
||||
class ArtistListFragment : ListFragment<FragmentHomeListBinding>(), 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<FragmentHomeListBinding>() {
|
|||
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<FragmentHomeListBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<ArtistViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER)
|
||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
@ -123,18 +133,14 @@ class ArtistListFragment : ListFragment<FragmentHomeListBinding>() {
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: ArtistViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
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<Artist>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>() {
|
||||
class GenreListFragment : ListFragment<FragmentHomeListBinding>(), 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<FragmentHomeListBinding>() {
|
|||
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<FragmentHomeListBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<GenreViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER)
|
||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
@ -122,14 +132,14 @@ class GenreListFragment : ListFragment<FragmentHomeListBinding>() {
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int, payloads: List<Any>) {
|
||||
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<Genre>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>() {
|
||||
class SongListFragment : ListFragment<FragmentHomeListBinding>(), 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<FragmentHomeListBinding>() {
|
|||
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<FragmentHomeListBinding>() {
|
|||
|
||||
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<FragmentHomeListBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<FragmentHomeListBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
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<SongViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFFER)
|
||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
@ -161,14 +172,14 @@ class SongListFragment : ListFragment<FragmentHomeListBinding>() {
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
SongViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int, payloads: List<Any>) {
|
||||
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<Song>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
@ -15,33 +15,32 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<MusicMode>) :
|
||||
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)
|
|
@ -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<Tab>): 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<Tab>? {
|
||||
val tabs = mutableListOf<Tab>()
|
||||
|
||||
|
|
|
@ -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<TabViewHolder>() {
|
||||
/**
|
||||
* 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<TabViewHolder>() {
|
||||
/**
|
||||
* 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<Tab>()
|
||||
private set
|
||||
|
||||
|
@ -37,66 +62,94 @@ class TabAdapter(private val callback: Callback) : RecyclerView.Adapter<TabViewH
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
|
||||
holder.bind(tabs[position], callback)
|
||||
holder.bind(tabs[position], listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately update the tab array. This should be used when initializing the list.
|
||||
* @param newTabs The new array of tabs to show.
|
||||
*/
|
||||
@Suppress("NotifyDatasetChanged")
|
||||
fun submitTabs(newTabs: Array<Tab>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<DialogTabsBinding>() {
|
||||
private val tabAdapter = TabAdapter(TabAdapter.Callback(::toggleVisibility, ::pickUpTab))
|
||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), 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<DialogTabsBinding>() {
|
|||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
|
||||
val savedTabs = findSavedTabState(savedInstanceState)
|
||||
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) {
|
||||
logD("Found saved tab state")
|
||||
tabAdapter.submitTabs(savedTabs)
|
||||
} else {
|
||||
tabAdapter.submitTabs(settings.libTabs)
|
||||
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<DialogTabsBinding>() {
|
|||
|
||||
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<DialogTabsBinding>() {
|
|||
binding.tabRecycler.adapter = null
|
||||
}
|
||||
|
||||
private fun toggleVisibility(mode: MusicMode) {
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == mode }
|
||||
if (index > -1) {
|
||||
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<Tab.Visible>().isNotEmpty()
|
||||
}
|
||||
|
||||
private fun pickUpTab(viewHolder: RecyclerView.ViewHolder) {
|
||||
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
|
||||
touchHelper.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
private fun findSavedTabState(savedInstanceState: Bundle?): Array<Tab>? {
|
||||
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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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?)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
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)
|
||||
/**
|
||||
* 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)
|
||||
|
||||
cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
|
||||
styledAttrs.recycle()
|
||||
}
|
||||
/**
|
||||
* 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 album cover for a [song]. */
|
||||
fun bind(song: Song) = loadImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
|
||||
/**
|
||||
* 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 album cover for an [album]. */
|
||||
fun bind(album: Album) = loadImpl(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 the image for a [genre] */
|
||||
fun bind(genre: Genre) = loadImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
|
||||
|
||||
private fun <T : Music> 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
|
||||
|
|
|
@ -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<Music> {
|
||||
override fun key(data: Music, options: Options) =
|
||||
if (data is Song) {
|
||||
|
@ -49,25 +52,31 @@ class MusicKeyer : Keyer<Music> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<Song> {
|
||||
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<Album> {
|
||||
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<Artist> {
|
||||
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<Genre> {
|
||||
|
@ -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 <T : Any, R : Any> Collection<T>.mapAtMost(
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
|
||||
n: Int,
|
||||
transform: (T) -> R?
|
||||
): List<R> {
|
||||
|
@ -130,11 +146,12 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
|
|||
val out = mutableListOf<R>()
|
||||
|
||||
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
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<InputStream>,
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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) {
|
|
@ -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<InputStream>,
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
15
app/src/main/java/org/oxycblt/auxio/list/Data.kt
Normal file
15
app/src/main/java/org/oxycblt/auxio/list/Data.kt
Normal file
|
@ -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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
|
@ -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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
||||
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<VB : ViewBinding> : SelectionFragment<VB>(), 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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
|||
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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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<VB : ViewBinding> : ViewBindingFragment<VB>() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
|
54
app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
Normal file
54
app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
Normal file
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
private fun invalidateDividers() {
|
||||
val manager = layoutManager as LinearLayoutManager
|
||||
topDivider.isInvisible = manager.findFirstCompletelyVisibleItemPosition() < 1
|
||||
bottomDivider.isInvisible =
|
||||
manager.findLastCompletelyVisibleItemPosition() == (manager.itemCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder that correctly resizes the item to match the parent width, which it is not normally in
|
||||
* dialogs.
|
||||
/**
|
||||
* Invalidate the visibility of both dividers.
|
||||
*/
|
||||
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)
|
||||
private fun invalidateDividers() {
|
||||
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 =
|
||||
lmm.findLastCompletelyVisibleItemPosition() == (lmm.itemCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
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<Any>) {
|
||||
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<Item>
|
||||
|
||||
/**
|
||||
* 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<VH : RecyclerView.ViewHolder> : 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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<VH : RecyclerView.ViewHolder> :
|
||||
PlayingIndicatorAdapter<VH>() {
|
||||
/**
|
||||
* 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<Music>()
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||
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<Music>) {
|
||||
val oldSelectedItems = selectedItems
|
||||
val newSelectedItems = items.toSet()
|
||||
if (newSelectedItems == oldSelectedItems) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -53,19 +66,20 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
|||
// 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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem
|
||||
|
|
|
@ -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<T>(
|
||||
adapter: RecyclerView.Adapter<*>,
|
||||
|
@ -109,17 +110,26 @@ class SyncListDiffer<T>(
|
|||
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<T>) {
|
||||
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<T>) {
|
||||
if (newList == currentList) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Song>() {
|
||||
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<Album>() {
|
||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
|
@ -125,34 +131,29 @@ 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.parentMenu.setOnClickListener { callback.onOpenMenu(item, it) }
|
||||
binding.root.apply {
|
||||
setOnClickListener { callback.onClick(item) }
|
||||
setOnLongClickListener {
|
||||
callback.onSelect(item)
|
||||
true
|
||||
}
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<Artist>() {
|
||||
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<Genre>() {
|
||||
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<Header>() {
|
||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||
oldItem.string == newItem.string
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<VB : ViewBinding> :
|
||||
ViewBindingFragment<VB>(), 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<Music>())
|
||||
/**
|
||||
* the currently selected items. These are ordered in earliest selected
|
||||
* and latest selected.
|
||||
*/
|
||||
val selected: StateFlow<List<Music>>
|
||||
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)
|
||||
}
|
||||
|
||||
/** Clear and return all selected items. */
|
||||
fun consume() = _selected.value.also { _selected.value = listOf() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
|
|
@ -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<Song>) : MusicParent() {
|
||||
override val uid =
|
||||
|
@ -584,7 +584,7 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : 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<Music>) : MusicParent() {
|
||||
override val uid =
|
||||
|
@ -702,7 +702,7 @@ class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicP
|
|||
|
||||
/**
|
||||
* A genre.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Genre constructor(private val raw: Raw, override val songs: List<Song>) : MusicParent() {
|
||||
override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) }
|
||||
|
|
|
@ -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<Callback>()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<Int>) : Comparable<Date> {
|
||||
init {
|
||||
|
@ -189,7 +189,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
* 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?
|
||||
|
|
|
@ -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<Long, Song.Raw>? = null
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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<ArtistChoiceViewHolder>() {
|
||||
private var artists = listOf<Artist>()
|
||||
|
||||
|
@ -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<Artist>) {
|
||||
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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<DialogMusicPickerBinding>() {
|
||||
abstract class ArtistPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), 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<DialogMusicPickerB
|
|||
binding.pickerRecycler.adapter = null
|
||||
}
|
||||
|
||||
open fun onChoiceConfirmed(item: Item) {
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.java}" }
|
||||
override fun onClick(item: Item) {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ import org.oxycblt.auxio.util.androidActivityViewModels
|
|||
|
||||
/**
|
||||
* The [ArtistPickerDialog] for ambiguous artist playback operations.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
|
@ -39,8 +39,8 @@ class ArtistPlaybackPickerDialog : 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}" }
|
||||
pickerModel.currentSong.value?.let { song -> playbackModel.playFromArtist(song, item) }
|
||||
}
|
||||
|
|
|
@ -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<MusicDirViewHolder>() {
|
||||
private val _dirs = mutableListOf<Directory>()
|
||||
|
@ -69,7 +69,7 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
|
|||
|
||||
/** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */
|
||||
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
|
||||
DialogViewHolder(binding.root) {
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: Directory, listener: MusicDirAdapter.Listener) {
|
||||
binding.dirPath.text = item.resolveName(binding.context)
|
||||
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) }
|
||||
|
|
|
@ -37,7 +37,7 @@ import org.oxycblt.auxio.util.showToast
|
|||
|
||||
/**
|
||||
* Dialog that manages the music dirs setting.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicDirsDialog :
|
||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<FragmentPlaybackBarBinding>() {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
|
|
|
@ -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<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
AuxioBottomSheetBehavior<V>(context, attributeSet) {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -38,7 +38,7 @@ import org.oxycblt.auxio.util.inflater
|
|||
|
||||
class QueueAdapter(private val listener: QueueItemListener) :
|
||||
RecyclerView.Adapter<QueueSongViewHolder>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
AuxioBottomSheetBehavior<V>(context, attributeSet) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<FragmentQueueBinding>(), QueueItemListener {
|
||||
private val queueModel: QueueViewModel by activityViewModels()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<DialogPreAmpBinding>() {
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -75,7 +75,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider
|
|||
*
|
||||
* TODO: Android Auto
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackService :
|
||||
Service(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||
private val differ = AsyncListDiffer(this, DIFFER)
|
||||
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
|
@ -53,46 +53,40 @@ class SearchAdapter(private val callback: ItemSelectCallback) :
|
|||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int) = differ.currentList[position] is Header
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
fun submitList(list: List<Item>, callback: () -> Unit) = differ.submitList(list, callback)
|
||||
fun submitList(list: List<Item>, callback: () -> Unit) {
|
||||
differ.submitList(list, callback)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFFER =
|
||||
private val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentSearchBinding>() {
|
||||
|
||||
// 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<FragmentSearchBinding>() {
|
|||
|
||||
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<FragmentSearchBinding>() {
|
|||
|
||||
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<FragmentSearchBinding>() {
|
|||
// --- 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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<FragmentAboutBinding>() {
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<FragmentSettingsBinding>() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AccentViewHolder>() {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<DialogAccentBinding>() {
|
||||
private var accentAdapter = AccentAdapter(ItemClickCallback(::handleClick))
|
||||
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>(), 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<DialogAccentBinding>() {
|
|||
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)
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue