home: make playlist add a speed dial

Add a speed dial menu that allows you to create a new playlist or
import a playlist from elsewhere.
This commit is contained in:
Alexander Capehart 2023-12-19 15:03:48 -07:00
parent d3de34ed5e
commit 08ca71b7b0
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 427 additions and 144 deletions

View file

@ -147,6 +147,9 @@ dependencies {
// Logging
implementation 'com.jakewharton.timber:timber:5.0.1'
// Speed dial
implementation "com.leinardi.android:speed-dial:3.3.0"
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"

View file

@ -1,117 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* FlipFloatingActionButton.kt is part of Auxio.
*
* 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.home
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.google.android.material.R
import com.google.android.material.floatingactionbutton.FloatingActionButton
import org.oxycblt.auxio.util.logD
/**
* An extension of [FloatingActionButton] that enables the ability to fade in and out between
* several states, as in the Material Design 3 specification.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class FlipFloatingActionButton
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.floatingActionButtonStyle
) : FloatingActionButton(context, attrs, defStyleAttr) {
private var pendingConfig: PendingConfig? = null
private var flipping = false
override fun show() {
// Will already show eventually, need to do nothing.
if (flipping) {
logD("Already flipping, aborting show")
return
}
// Apply the new configuration possibly set in flipTo. This should occur even if
// a flip was canceled by a hide.
pendingConfig?.run {
logD("Applying pending configuration")
setImageResource(iconRes)
contentDescription = context.getString(contentDescriptionRes)
setOnClickListener(clickListener)
}
pendingConfig = null
logD("Beginning show")
super.show()
}
override fun hide() {
if (flipping) {
logD("Hide was called, aborting flip")
}
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
flipping = false
// Don't pass any kind of listener so that future flip operations will not be able
// to show the FAB again.
logD("Beginning hide")
super.hide()
}
/**
* Flip to a new FAB state.
*
* @param iconRes The resource of the new FAB icon.
* @param contentDescriptionRes The resource of the new FAB content description.
*/
fun flipTo(
@DrawableRes iconRes: Int,
@StringRes contentDescriptionRes: Int,
clickListener: OnClickListener
) {
// Avoid doing a flip if the given config is already being applied.
if (tag == iconRes) return
tag = iconRes
pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener)
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
if (!isOrWillBeHidden) {
logD("Starting hide for flip")
flipping = true
// We will re-show the FAB later, assuming that there was not a prior flip operation.
super.hide(FlipVisibilityListener())
} else {
logD("Already hiding, will apply config later")
}
}
private data class PendingConfig(
@DrawableRes val iconRes: Int,
@StringRes val contentDescriptionRes: Int,
val clickListener: OnClickListener
)
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
if (!flipping) return
logD("Starting show for flip")
flipping = false
show()
}
}
}

View file

@ -36,10 +36,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
@ -71,6 +73,7 @@ import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
@ -166,6 +169,35 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
binding.homeShuffleFab.setOnClickListener {
logD("Shuffling")
playbackModel.shuffleAll()
}
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener { action ->
when (action.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
TODO("Not implemented")
}
else -> {}
}
close()
true
}
}
hideAllFabs()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
@ -291,17 +323,7 @@ class HomeFragment :
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
}
if (tabType != MusicType.PLAYLISTS) {
logD("Flipping to shuffle button")
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
playbackModel.shuffleAll()
}
} else {
logD("Flipping to playlist button")
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
musicModel.createPlaylist()
}
}
updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun handleRecreate(recreate: Unit?) {
@ -333,7 +355,10 @@ class HomeFragment :
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
logD("Received ok response")
binding.homeFab.show()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
binding.homeIndexingContainer.visibility = View.INVISIBLE
return
}
@ -440,16 +465,75 @@ class HomeFragment :
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
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) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
binding.homeFab.hide()
hideAllFabs()
} else {
logD("Showing fab")
binding.homeFab.show()
if (tabType != MusicType.PLAYLISTS) {
logD("Showing shuffle button")
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
logD(binding.homeShuffleFab.isOrWillBeShown)
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun hideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
@ -568,6 +652,12 @@ class HomeFragment :
private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
}
}

View file

@ -0,0 +1,256 @@
/*
* Copyright (c) 2018 Auxio Project
* ThemedSpeedDialView.kt is part of Auxio.
*
* 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.home
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.RotateDrawable
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.util.Property
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.FloatRange
import androidx.core.os.BundleCompat
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.leinardi.android.speeddial.FabWithLabelView
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import kotlin.math.roundToInt
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
/**
* Customized Speed Dial view with some bug fixes and Material 3 theming.
*
* Adapted from Material Files:
* https://github.com/zhanghai/MaterialFiles/tree/79f1727cec72a6a089eb495f79193f87459fc5e3
*
* MODIFICATIONS:
* - Removed dynamic theme changes based on the MaterialFile's Material 3 setting
* - Adapted code to the extensions in this project
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
class ThemedSpeedDialView : SpeedDialView {
private var mainFabAnimator: Animator? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
@AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
init {
// Work around ripple bug on Android 12 when useCompatPadding = true.
// @see https://github.com/material-components/material-components-android/issues/2617
mainFab.apply {
updateLayoutParams<MarginLayoutParams> {
setMargins(context.getDimenPixels(R.dimen.spacing_medium))
}
useCompatPadding = false
}
val context = context
mainFabClosedBackgroundColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondaryContainer)
.defaultColor
mainFabClosedIconColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondaryContainer)
.defaultColor
mainFabOpenedBackgroundColor =
context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary).defaultColor
mainFabOpenedIconColor =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimary)
.defaultColor
// Always use our own animation to fix the library issue that ripple is rotated as well.
val mainFabDrawable =
RotateDrawable().apply {
drawable = mainFab.drawable
toDegrees = mainFabAnimationRotateAngle
}
mainFabAnimationRotateAngle = 0f
setMainFabClosedDrawable(mainFabDrawable)
setOnChangeListener(
object : OnChangeListener {
override fun onMainActionSelected(): Boolean = false
override fun onToggleChanged(isOpen: Boolean) {
mainFabAnimator?.cancel()
mainFabAnimator =
createMainFabAnimator(isOpen).apply {
addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
mainFabAnimator = null
}
})
start()
}
}
})
}
private fun createMainFabAnimator(isOpen: Boolean): Animator =
AnimatorSet().apply {
playTogether(
ObjectAnimator.ofArgb(
mainFab,
VIEW_PROPERTY_BACKGROUND_TINT,
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
ObjectAnimator.ofArgb(
mainFab,
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
ObjectAnimator.ofInt(
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
duration = 200
interpolator = FastOutSlowInInterpolator()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val overlayLayout = overlayLayout
if (overlayLayout != null) {
val surfaceColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor)
}
}
private fun Int.withModulatedAlpha(
@FloatRange(from = 0.0, to = 1.0) alphaModulation: Float
): Int {
val alpha = (alpha * alphaModulation).roundToInt()
return ((alpha shl 24) or (this and 0x00FFFFFF))
}
override fun addActionItem(
actionItem: SpeedDialActionItem,
position: Int,
animate: Boolean
): FabWithLabelView? {
val context = context
val fabImageTintColor = context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary)
val fabBackgroundColor =
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary)
val labelBackgroundColor = Color.TRANSPARENT
val actionItem =
SpeedDialActionItem.Builder(
actionItem.id,
// Should not be a resource, pass null to fail fast.
actionItem.getFabImageDrawable(null))
.setLabel(actionItem.getLabel(context))
.setFabImageTintColor(fabImageTintColor.defaultColor)
.setFabBackgroundColor(fabBackgroundColor.defaultColor)
.setLabelColor(labelColor.defaultColor)
.setLabelBackgroundColor(labelBackgroundColor)
.setLabelClickable(actionItem.isLabelClickable)
.setTheme(actionItem.theme)
.create()
return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply {
updateLayoutParams<MarginLayoutParams> {
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
setMargins(horizontalMargin, 0, horizontalMargin, 0)
}
useCompatPadding = false
}
labelBackground.apply {
useCompatPadding = false
setContentPadding(0, 0, 0, 0)
foreground = null
(getChildAt(0) as TextView).apply {
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_LabelLarge)
}
}
}
}
override fun onSaveInstanceState(): Parcelable {
val superState =
BundleCompat.getParcelable(
super.onSaveInstanceState() as Bundle, "superState", Parcelable::class.java)
return State(superState, isOpen)
}
override fun onRestoreInstanceState(state: Parcelable) {
state as State
super.onRestoreInstanceState(state.superState)
if (state.isOpen) {
toggle(false)
}
}
companion object {
private val VIEW_PROPERTY_BACKGROUND_TINT =
object : Property<View, Int>(Int::class.java, "backgroundTint") {
override fun get(view: View): Int? = view.backgroundTintList!!.defaultColor
override fun set(view: View, value: Int?) {
view.backgroundTintList = ColorStateList.valueOf(value!!)
}
}
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
object : Property<ImageView, Int>(Int::class.java, "imageTint") {
override fun get(view: ImageView): Int? = view.imageTintList!!.defaultColor
override fun set(view: ImageView, value: Int?) {
view.imageTintList = ColorStateList.valueOf(value!!)
}
}
private val DRAWABLE_PROPERTY_LEVEL =
object : Property<Drawable, Int>(Int::class.java, "level") {
override fun get(drawable: Drawable): Int? = drawable.level
override fun set(drawable: Drawable, value: Int?) {
drawable.level = value!!
}
}
}
@Parcelize private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
}

View file

@ -435,7 +435,8 @@ constructor(
// To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop.
incompleteSongs.close(e)
logE("MediaStore extraction failed: $e")
incompleteSongs.close(Exception("MediaStore extraction failed: e"))
return@async
}
incompleteSongs.close()
@ -449,8 +450,8 @@ constructor(
try {
tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) {
logD("Tag extraction failed: $e")
completeSongs.close(e)
logE("Tag extraction failed: $e")
completeSongs.close(Exception("Tag extraction failed: $e"))
return@async
}
completeSongs.close()
@ -466,8 +467,8 @@ constructor(
deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) {
logD("DeviceLibrary creation failed: $e")
processedSongs.close(e)
logE("DeviceLibrary creation failed: $e")
processedSongs.close(Exception("DeviceLibrary creation failed: $e"))
return@async Result.failure(e)
}
processedSongs.close()

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/>
</vector>

View file

@ -8,6 +8,7 @@
android:background="?attr/colorSurface"
android:transitionGroup="true">
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout">
@ -147,18 +148,43 @@
</FrameLayout>
<org.oxycblt.auxio.home.EdgeFrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="match_parent"
app:layout_anchor="@id/home_content"
app:layout_anchorGravity="bottom|end">
android:layout_gravity="bottom|end"
android:layout_height="match_parent">
<org.oxycblt.auxio.home.FlipFloatingActionButton
android:id="@+id/home_fab"
style="@style/Widget.Auxio.FloatingActionButton.Adaptive"
<com.leinardi.android.speeddial.SpeedDialOverlayLayout
android:id="@+id/home_speed_dial_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<org.oxycblt.auxio.home.ThemedSpeedDialView
android:id="@+id/home_new_playlist_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/spacing_medium" />
app:sdMainFabAnimationRotateAngle="135"
android:clickable="true"
android:focusable="true"
android:gravity="bottom|end"
app:sdMainFabClosedSrc="@drawable/ic_add_24"
android:layout_gravity="bottom|end"
app:sdMainFabClosedIconColor="@android:color/white"
app:sdOverlayLayout="@+id/home_speed_dial_overlay" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/home_shuffle_fab"
android:layout_margin="@dimen/spacing_medium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:src="@drawable/ic_shuffle_off_24" />
</org.oxycblt.auxio.home.EdgeFrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_import_playlist"
android:icon="@drawable/ic_import_24"
android:title="@string/lbl_import_playlist" />
<item
android:id="@+id/action_new_playlist"
android:icon="@drawable/ic_playlist_24"
android:title="@string/lbl_empty_playlist" />
</menu>

View file

@ -88,6 +88,8 @@
<string name="lbl_playlist">Playlist</string>
<string name="lbl_playlists">Playlists</string>
<string name="lbl_new_playlist">New playlist</string>
<string name="lbl_empty_playlist">Empty playlist</string>
<string name="lbl_import_playlist">Imported playlist</string>
<string name="lbl_rename">Rename</string>
<string name="lbl_rename_playlist">Rename playlist</string>
<string name="lbl_delete">Delete</string>