From 08ca71b7b0a9d04e97cc1fab6736ec8be3569a1c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 19 Dec 2023 15:03:48 -0700 Subject: [PATCH] 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. --- app/build.gradle | 3 + .../auxio/home/FlipFloatingActionButton.kt | 117 -------- .../org/oxycblt/auxio/home/HomeFragment.kt | 120 +++++++- .../oxycblt/auxio/home/ThemedSpeedDialView.kt | 256 ++++++++++++++++++ .../oxycblt/auxio/music/MusicRepository.kt | 11 +- app/src/main/res/drawable/ic_import_24.xml | 11 + app/src/main/res/layout/fragment_home.xml | 40 ++- .../main/res/menu/new_playlist_actions.xml | 11 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 427 insertions(+), 144 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt create mode 100644 app/src/main/res/drawable/ic_import_24.xml create mode 100644 app/src/main/res/menu/new_playlist_actions.xml diff --git a/app/build.gradle b/app/build.gradle index f414337b8..5d07581f0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt deleted file mode 100644 index c3cd4a82f..000000000 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ /dev/null @@ -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 . - */ - -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() - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 01558611d..e49c08a45 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -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, isFastScrolling: Boolean) { + updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value) + } + + private fun updateFabVisibility( + songs: List, + 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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt new file mode 100644 index 000000000..845bc617d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt @@ -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 . + */ + +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 { + 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 { + 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(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(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(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 +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index c61c4c5ad..b223e28c8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -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() diff --git a/app/src/main/res/drawable/ic_import_24.xml b/app/src/main/res/drawable/ic_import_24.xml new file mode 100644 index 000000000..f63386171 --- /dev/null +++ b/app/src/main/res/drawable/ic_import_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index f1b5c8c80..d24c1a2d3 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -8,6 +8,7 @@ android:background="?attr/colorSurface" android:transitionGroup="true"> + @@ -147,18 +148,43 @@ + android:layout_gravity="bottom|end" + android:layout_height="match_parent"> - + + + 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" /> + + + + + + + diff --git a/app/src/main/res/menu/new_playlist_actions.xml b/app/src/main/res/menu/new_playlist_actions.xml new file mode 100644 index 000000000..7b9916426 --- /dev/null +++ b/app/src/main/res/menu/new_playlist_actions.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7866d184..f00389de8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,6 +88,8 @@ Playlist Playlists New playlist + Empty playlist + Imported playlist Rename Rename playlist Delete