From 917540e62686f0f9667e427d87fe85cc5d64d8c7 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 27 Feb 2021 13:00:46 -0700 Subject: [PATCH] Refactor fast scroll Completely write my own fast scroller thumb and also redo how the fast scroller is configured in SongsFragment. --- .../java/org/oxycblt/auxio/coil/CoilUtils.kt | 8 +- .../auxio/playback/PlaybackViewModel.kt | 12 +- .../playback/state/PlaybackStateManager.kt | 10 +- .../auxio/playback/system/PlaybackService.kt | 38 +--- .../oxycblt/auxio/songs/CobaltScrollThumb.kt | 112 ++++++++++++ .../org/oxycblt/auxio/songs/SongsFragment.kt | 163 ++++++++---------- .../org/oxycblt/auxio/ui/InterfaceUtils.kt | 18 ++ app/src/main/res/layout/fragment_songs.xml | 2 +- app/src/main/res/values/styles.xml | 3 +- build.gradle | 2 +- 10 files changed, 225 insertions(+), 143 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt diff --git a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt index 556a9cda4..bb2e34a8a 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt @@ -12,6 +12,7 @@ import coil.request.ImageRequest import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.settings.SettingsManager @@ -53,8 +54,12 @@ fun ImageView.bindGenreImage(genre: Genre) { /** * Custom extension function similar to the stock coil load extensions, but handles whether * to show images and custom fetchers. + * @param T Any datatype that inherits [BaseModel] + * @param data The data itself + * @param error Drawable resource to use when loading failed/should not occur. + * @param fetcher Required fetcher that uses [T] as its datatype */ -inline fun ImageView.load( +inline fun ImageView.load( data: T, @DrawableRes error: Int, fetcher: Fetcher, @@ -63,7 +68,6 @@ inline fun ImageView.load( if (!settingsManager.showCovers) { setImageResource(error) - return } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index f7a523eff..224dfd3bb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -40,8 +40,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { private val mPosition = MutableLiveData(0L) // Queue - private val mQueue = MutableLiveData(mutableListOf()) - private val mUserQueue = MutableLiveData(mutableListOf()) + private val mQueue = MutableLiveData(listOf()) + private val mUserQueue = MutableLiveData(listOf()) private val mIndex = MutableLiveData(0) private val mMode = MutableLiveData(PlaybackMode.ALL_SONGS) @@ -64,9 +64,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { val position: LiveData get() = mPosition /** The current queue determined by [mode] and [parent] */ - val queue: LiveData> get() = mQueue + val queue: LiveData> get() = mQueue /** The queue created by the user. */ - val userQueue: LiveData> get() = mUserQueue + val userQueue: LiveData> get() = mUserQueue /** The current [PlaybackMode] that also determines the queue */ val mode: LiveData get() = mMode /** Whether playback is originating from the user-generated queue or not */ @@ -451,11 +451,11 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { } } - override fun onQueueUpdate(queue: MutableList) { + override fun onQueueUpdate(queue: List) { mQueue.value = queue } - override fun onUserQueueUpdate(userQueue: MutableList) { + override fun onUserQueueUpdate(userQueue: List) { mUserQueue.value = userQueue } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 2d137bf97..f9ded9c7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -101,9 +101,9 @@ class PlaybackStateManager private constructor() { /** The current playback progress */ val position: Long get() = mPosition /** The current queue determined by [parent] and [mode] */ - val queue: MutableList get() = mQueue + val queue: List get() = mQueue /** The queue created by the user. */ - val userQueue: MutableList get() = mUserQueue + val userQueue: List get() = mUserQueue /** The current index of the queue */ val index: Int get() = mIndex /** The current [PlaybackMode] */ @@ -116,7 +116,7 @@ class PlaybackStateManager private constructor() { val loopMode: LoopMode get() = mLoopMode /** Whether this instance has already been restored */ val isRestored: Boolean get() = mIsRestored - /** Whether this instance has started playing or not */ + /** Whether playback has begun in this instance during **PlaybackService's Lifecycle.** */ val hasPlayed: Boolean get() = mHasPlayed private val settingsManager = SettingsManager.getInstance() @@ -788,8 +788,8 @@ class PlaybackStateManager private constructor() { fun onSongUpdate(song: Song?) {} fun onParentUpdate(parent: Parent?) {} fun onPositionUpdate(position: Long) {} - fun onQueueUpdate(queue: MutableList) {} - fun onUserQueueUpdate(userQueue: MutableList) {} + fun onQueueUpdate(queue: List) {} + fun onUserQueueUpdate(userQueue: List) {} fun onModeUpdate(mode: PlaybackMode) {} fun onIndexUpdate(index: Int) {} fun onPlayingUpdate(isPlaying: Boolean) {} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 83c18ce68..a6b2fdfcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -252,7 +252,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca } override fun onLoopUpdate(loopMode: LoopMode) { - player.setLoopMode(loopMode) + player.repeatMode = if (loopMode == LoopMode.NONE) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ALL + } if (!settingsManager.useAltNotifAction) { notification.setLoop(this, loopMode) @@ -344,27 +348,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca onLoopUpdate(playbackManager.loopMode) onSongUpdate(playbackManager.song) onSeek(playbackManager.position) - - /* - Old Manual restore code, restore this if the above causes bugs - notification.setParent(this, playbackManager.parent) - notification.setPlaying(this, playbackManager.isPlaying) - - if (settingsManager.useAltNotifAction) { - notification.setShuffle(this, playbackManager.isShuffling) - } else { - notification.setLoop(this, playbackManager.loopMode) - } - - player.setLoopMode(playbackManager.loopMode) - - playbackManager.song?.let { song -> - notification.setMetadata(this, song, settingsManager.colorizeNotif) {} - - player.setMediaItem(MediaItem.fromUri(song.id.toURI())) - player.seekTo(playbackManager.position) - player.prepare() - }*/ } /** @@ -406,17 +389,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca } } - /** - * Shortcut to transform a [LoopMode] into a player repeat mode - */ - private fun Player.setLoopMode(mode: LoopMode) { - repeatMode = if (mode == LoopMode.NONE) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ALL - } - } - /** * Bring the service into the foreground and show the notification, or refresh the notification. */ diff --git a/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt b/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt new file mode 100644 index 000000000..e35b42e71 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt @@ -0,0 +1,112 @@ +package org.oxycblt.auxio.songs + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.ViewGroup +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce +import com.reddit.indicatorfastscroll.FastScrollItemIndicator +import com.reddit.indicatorfastscroll.FastScrollerView +import org.oxycblt.auxio.R +import org.oxycblt.auxio.ui.Accent +import org.oxycblt.auxio.ui.addIndicatorCallback +import org.oxycblt.auxio.ui.inflater + +/** + * A slimmed-down variant of [com.reddit.indicatorfastscroll.FastScrollerThumbView] designed + * specifically for Auxio. Also fixes a memory leak that occurs from a bug fix they + * added. + * @author OxygenCobalt + */ +class CobaltScrollThumb @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = -1 +) : ConstraintLayout(context, attrs, defStyleAttr) { + private val thumbView: ViewGroup + private val textView: TextView + private val thumbAnim: SpringAnimation + + init { + context.inflater.inflate(R.layout.fast_scroller_thumb_view, this, true) + + val accent = Accent.get().getStateList(context) + + thumbView = findViewById(R.id.fast_scroller_thumb).apply { + textView = findViewById(R.id.fast_scroller_thumb_text) + + backgroundTintList = accent + + // Workaround for API 21 tint bug + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) { + (background as GradientDrawable).apply { + mutate() + color = accent + } + } + } + + textView.apply { + isVisible = true + TextViewCompat.setTextAppearance(this, R.style.TextAppearance_ThumbIndicator) + } + + thumbAnim = SpringAnimation(thumbView, DynamicAnimation.TRANSLATION_Y).apply { + spring = SpringForce().also { + it.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY + } + } + + isActivated = false + } + + /** + * Set up this view with a [FastScrollerView]. Should only be called once. + */ + fun setup(scrollView: FastScrollerView) { + scrollView.addIndicatorCallback { indicator, centerY, _ -> + thumbAnim.animateToFinalPosition(centerY.toFloat() - (thumbView.measuredHeight / 2)) + + if (indicator is FastScrollItemIndicator.Text) { + textView.text = indicator.text + } + } + + @Suppress("ClickableViewAccessibility") + scrollView.setOnTouchListener { v, event -> + scrollView.onTouchEvent(event) + scrollView.performClick() + + val action = event.actionMasked + + // If we arent deselecting the scroll view, determine if we are selecting an item. + isActivated = if ( + action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_CANCEL + ) isPointerOnItem(scrollView, event.y.toInt()) else false + + true + } + } + + /** + * Hack that determines whether the pointer is currently on the [scrollView] or not. + */ + private fun isPointerOnItem(scrollView: FastScrollerView, touchY: Int): Boolean { + scrollView.children.forEach { child -> + if (touchY in (child.top until child.bottom)) { + return true + } + } + + return false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt index ab2880789..24ec23cce 100644 --- a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt @@ -10,6 +10,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.reddit.indicatorfastscroll.FastScrollItemIndicator import com.reddit.indicatorfastscroll.FastScrollerView import org.oxycblt.auxio.R @@ -18,6 +19,7 @@ import org.oxycblt.auxio.logD import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.Accent +import org.oxycblt.auxio.ui.addIndicatorCallback import org.oxycblt.auxio.ui.canScroll import org.oxycblt.auxio.ui.getSpans import org.oxycblt.auxio.ui.newMenu @@ -57,9 +59,7 @@ class SongsFragment : Fragment() { if (it.itemId == R.id.action_shuffle) { playbackModel.shuffleAll() true - } - - false + } else false } } @@ -82,114 +82,89 @@ class SongsFragment : Fragment() { } } - setupFastScroller(binding) + binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb) logD("Fragment created.") return binding.root } - override fun onDestroyView() { - requireView().rootView.clearFocus() - - super.onDestroyView() - } - /** - * Go through the fast scroller setup process. - * @param binding Binding required + * Perform the (Frustratingly Long and Complicated) FastScrollerView setup. */ - private fun setupFastScroller(binding: FragmentSongsBinding) { - binding.songFastScroll.apply { - var concatInterval = -1 + private fun FastScrollerView.setup(recycler: RecyclerView, thumb: CobaltScrollThumb) { + var concatInterval: Int = -1 - // API 22 and below don't support the state color, so just use the accent. - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { - textColor = Accent.get().getStateList(requireContext()) + // API 22 and below don't support the state color, so just use the accent. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + textColor = Accent.get().getStateList(requireContext()) + } + + setupWithRecyclerView( + recycler, + { pos -> + val char = musicStore.songs[pos].name.first + + // Use "#" if the character is a digit, also has the nice side-effect of + // truncating extra numbers. + if (char.isDigit()) { + FastScrollItemIndicator.Text("#") + } else { + FastScrollItemIndicator.Text(char.toString()) + } + }, + null, false + ) + + showIndicator = { _, i, total -> + if (concatInterval == -1) { + // If the scroller size is too small to contain all the entries, truncate entries + // so that the fast scroller entries fit. + val maxEntries = (height / (indicatorTextSize + textPadding)) + + if (total > maxEntries.toInt()) { + concatInterval = ceil(total / maxEntries).toInt() + + check(concatInterval > 1) { + "Needed to truncate, but concatInterval was 1 or lower anyway" + } + + logD("More entries than screen space, truncating by $concatInterval.") + } else { + concatInterval = 1 + } } - setupWithRecyclerView( - binding.songRecycler, - { pos -> - val item = musicStore.songs[pos] + // Any items that need to be truncated will be hidden + (i % concatInterval) == 0 + } - // If the item starts with "the"/"a", then actually use the character after that - // as its initial. Yes, this is stupidly western-centric but the code [hopefully] - // shouldn't run with other languages. - val char: Char = if (item.name.length > 5 && - item.name.startsWith("the ", ignoreCase = true) - ) { - item.name[4].toUpperCase() - } else if (item.name.length > 3 && - item.name.startsWith("a ", ignoreCase = true) - ) { - item.name[2].toUpperCase() - } else { - // If it doesn't begin with that word, then just use the first character. - item.name[0].toUpperCase() - } + addIndicatorCallback { _, _, pos -> + recycler.apply { + (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) - // Use "#" if the character is a digit, also has the nice side-effect of - // truncating extra numbers. - if (char.isDigit()) { - FastScrollItemIndicator.Text("#") - } else { - FastScrollItemIndicator.Text(char.toString()) - } - } - ) - - showIndicator = { _, i, total -> - var isGood = true - - if (concatInterval == -1) { - // If the scroller size is too small to contain all the entries, truncate entries - // so that the fast scroller entries fit. - val maxEntries = (height / (indicatorTextSize + textPadding)) - - if (total > maxEntries.toInt()) { - concatInterval = ceil(total / maxEntries).toInt() - - logD("More entries than screen space, truncating by $concatInterval.") - - check(concatInterval > 1) { - "ConcatInterval was one despite truncation being needed" - } - } else { - concatInterval = 1 - } - } - - if ((i % concatInterval) != 0) { - isGood = false - } - - isGood - } - - useDefaultScroller = false - - addIndicatorCallback { pos -> - binding.songRecycler.apply { - (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) - - stopScroll() - } + stopScroll() } } - binding.songFastScrollThumb.setupWithFastScroller(binding.songFastScroll) + thumb.setup(this) } - private fun FastScrollerView.addIndicatorCallback(callback: (pos: Int) -> Unit) { - itemIndicatorSelectedCallbacks.add( - object : FastScrollerView.ItemIndicatorSelectedCallback { - override fun onItemIndicatorSelected( - indicator: FastScrollItemIndicator, - indicatorCenterY: Int, - itemPosition: Int - ) = callback(itemPosition) - } - ) + /** + * Dumb shortcut for getting the first letter in a string, while regarding certain + * semantics when it comes to articles. + */ + private val String.first: Char get() { + // If the name actually starts with "The" or "A", get the character *after* that word. + // Yes, this is stupidly english centric but it wont run with other languages. + if (length > 5 && startsWith("the ", true)) { + return get(4).toUpperCase() + } + + if (length > 3 && startsWith("a ", true)) { + return get(2).toUpperCase() + } + + return get(0).toUpperCase() } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt index c3a9732fc..081ce4300 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt @@ -23,6 +23,8 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton +import com.reddit.indicatorfastscroll.FastScrollItemIndicator +import com.reddit.indicatorfastscroll.FastScrollerView import org.oxycblt.auxio.R import org.oxycblt.auxio.logE @@ -121,6 +123,22 @@ fun String.createToast(context: Context) { Toast.makeText(context.applicationContext, this, Toast.LENGTH_SHORT).show() } +/** + * Shortcut that allows me to add a indicator callback to [FastScrollerView] without + * the nightmarish boilerplate that entails. + */ +fun FastScrollerView.addIndicatorCallback( + callback: (indicator: FastScrollItemIndicator, centerY: Int, pos: Int) -> Unit +) { + itemIndicatorSelectedCallbacks += object : FastScrollerView.ItemIndicatorSelectedCallback { + override fun onItemIndicatorSelected( + indicator: FastScrollItemIndicator, + indicatorCenterY: Int, + itemPosition: Int + ) = callback(indicator, indicatorCenterY, itemPosition) + } +} + // --- CONFIGURATION --- /** diff --git a/app/src/main/res/layout/fragment_songs.xml b/app/src/main/res/layout/fragment_songs.xml index 0a05b159a..e47c39153 100644 --- a/app/src/main/res/layout/fragment_songs.xml +++ b/app/src/main/res/layout/fragment_songs.xml @@ -39,7 +39,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/song_toolbar" /> - @@ -112,6 +112,7 @@ diff --git a/build.gradle b/build.gradle index 9dd53c896..cc2ef9de1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.4.30' + ext.kotlin_version = '1.4.31' repositories { google()