diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f8092d2..2063c3684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 3.1.3 + +#### What's New +- Updated to Android 14 +- Added option to re-enable old album cover cropping behavior + +#### What's Improved +- `album artists` and `(album)artists sort` are now recognized +- Increased distinction from shuffle on/off icons + +#### What's Fixed +- Fixed an issue where the queue sheet would not collapse when scrolling +the song list in some cases +- Fixed music loading hanging if it encountered an error in certain places + ## 3.1.2 #### What's Improved diff --git a/README.md b/README.md index ccb7bcb0d..79a600389 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index d0ddac776..0a14a0b74 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,7 +10,7 @@ plugins { } android { - compileSdk 33 + compileSdk 34 // NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the @@ -20,11 +20,11 @@ android { defaultConfig { applicationId namespace - versionName "3.1.2" - versionCode 32 + versionName "3.1.3" + versionCode 33 minSdk 24 - targetSdk 33 + targetSdk 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -84,16 +84,19 @@ dependencies { // --- SUPPORT --- // General - implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.activity:activity-ktx:1.7.2" - implementation "androidx.fragment:fragment-ktx:1.5.7" + implementation "androidx.fragment:fragment-ktx:1.6.0" - // UI - implementation "androidx.recyclerview:recyclerview:1.3.0" + // Components + // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on + // certain upwards scrolling events + // TODO: Report this issue and hope for a timely fix + // noinspection GradleDependency + implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.viewpager2:viewpager2:1.1.0-beta02" - implementation 'androidx.core:core-ktx:1.10.1' + implementation "androidx.viewpager2:viewpager2:1.0.0" // Lifecycle def lifecycle_version = "2.6.1" @@ -128,11 +131,9 @@ dependencies { implementation 'io.coil-kt:coil-base:2.4.0' // Material - // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout is actually available - // in a version that I can build with // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.8.0-alpha01" + implementation "com.google.android.material:material:1.10.0-alpha04" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fd7b28a4a..ba4b27028 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index c6560c151..ab55a48dc 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -16,10 +16,14 @@ package com.google.android.material.bottomsheet; +import com.google.android.material.R; + import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static java.lang.Math.max; import static java.lang.Math.min; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.content.Context; @@ -32,8 +36,10 @@ import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.util.Log; +import android.util.SparseIntArray; import android.util.TypedValue; import android.view.MotionEvent; +import android.view.RoundedCorner; import android.view.VelocityTracker; import android.view.View; import android.view.View.MeasureSpec; @@ -41,13 +47,15 @@ import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; import android.view.ViewParent; +import android.view.WindowInsets; import android.view.accessibility.AccessibilityEvent; - +import androidx.activity.BackEventCompat; import androidx.annotation.FloatRange; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; +import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.annotation.StringRes; import androidx.annotation.VisibleForTesting; @@ -62,14 +70,13 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.Accessibilit import androidx.core.view.accessibility.AccessibilityViewCommand; import androidx.customview.view.AbsSavedState; import androidx.customview.widget.ViewDragHelper; - -import com.google.android.material.R; import com.google.android.material.internal.ViewUtils; import com.google.android.material.internal.ViewUtils.RelativePadding; +import com.google.android.material.motion.MaterialBackHandler; +import com.google.android.material.motion.MaterialBottomContainerBackHelper; import com.google.android.material.resources.MaterialResources; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.ShapeAppearanceModel; - import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.ref.WeakReference; @@ -84,13 +91,11 @@ import java.util.Map; *

To send useful accessibility events, set a title on bottom sheets that are windows or are * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. - * - * Modified at several points by Alexander Capehart to backport miscellaneous fixes not currently - * obtainable in the currently used MDC library. */ -public class BackportBottomSheetBehavior extends CoordinatorLayout.Behavior { +public class BackportBottomSheetBehavior extends CoordinatorLayout.Behavior + implements MaterialBackHandler { - /** Listener for monitoring events about bottom sheets. */ + /** Callback for monitoring events about bottom sheets. */ public abstract static class BottomSheetCallback { /** @@ -203,11 +208,11 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo @Retention(RetentionPolicy.SOURCE) public @interface SaveFlags {} - private static final String TAG = "BottomSheetBehavior"; + private static final String TAG = "BackportBottomSheetBehavior"; @SaveFlags private int saveFlags = SAVE_NONE; - private static final int SIGNIFICANT_VEL_THRESHOLD = 500; + @VisibleForTesting static final int DEFAULT_SIGNIFICANT_VEL_THRESHOLD = 500; private static final float HIDE_THRESHOLD = 0.5f; @@ -217,12 +222,21 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo private static final int NO_MAX_SIZE = -1; + private static final int VIEW_INDEX_BOTTOM_SHEET = 0; + + private static final int INVALID_POSITION = -1; + + @VisibleForTesting + static final int VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW = 1; + private boolean fitToContents = true; private boolean updateImportantForAccessibilityOnSiblings = false; private float maximumVelocity; + private int significantVelocityThreshold; + /** Peek height set by the user. */ private int peekHeight; @@ -256,10 +270,12 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo private int insetBottom; private int insetTop; + private boolean shouldRemoveExpandedCorners; + /** Default Shape Appearance to be used in bottomsheet */ private ShapeAppearanceModel shapeAppearanceModelDefault; - private boolean isShapeExpanded; + private boolean expandedCornersRemoved; private final StateSettlingTracker stateSettlingTracker = new StateSettlingTracker(); @@ -304,22 +320,25 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo int parentHeight; @Nullable WeakReference viewRef; + @Nullable WeakReference accessibilityDelegateViewRef; @Nullable WeakReference nestedScrollingChildRef; @NonNull private final ArrayList callbacks = new ArrayList<>(); @Nullable private VelocityTracker velocityTracker; + @Nullable MaterialBottomContainerBackHelper bottomContainerBackHelper; int activePointerId; - private int initialY; + private int initialY = INVALID_POSITION; boolean touchingScrollingChild; @Nullable private Map importantForAccessibilityMap; - private int expandHalfwayActionId = View.NO_ID; + @VisibleForTesting + final SparseIntArray expandHalfwayActionIds = new SparseIntArray(); public BackportBottomSheetBehavior() {} @@ -387,6 +406,11 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset, 0)); } + setSignificantVelocityThreshold( + a.getInt( + R.styleable.BottomSheetBehavior_Layout_behavior_significantVelocityThreshold, + DEFAULT_SIGNIFICANT_VEL_THRESHOLD)); + // Reading out if we are handling padding, so we can apply it to the content. paddingBottomSystemWindowInsets = a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingBottomSystemWindowInsets, false); @@ -404,6 +428,8 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginRightSystemWindowInsets, false); marginTopSystemWindowInsets = a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginTopSystemWindowInsets, false); + shouldRemoveExpandedCorners = + a.getBoolean(R.styleable.BottomSheetBehavior_Layout_shouldRemoveExpandedCorners, true); a.recycle(); ViewConfiguration configuration = ViewConfiguration.get(context); @@ -440,6 +466,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo // first time we layout with this behavior by checking (viewRef == null). viewRef = null; viewDragHelper = null; + bottomContainerBackHelper = null; } @Override @@ -448,6 +475,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo // Release references so we don't run unnecessary codepaths while not attached to a view. viewRef = null; viewDragHelper = null; + bottomContainerBackHelper = null; } @Override @@ -515,7 +543,9 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo peekHeightMin = parent.getResources().getDimensionPixelSize(R.dimen.design_bottom_sheet_peek_height_min); setWindowInsetsListener(child); + ViewCompat.setWindowInsetsAnimationCallback(child, new InsetsAnimationCallback(child)); viewRef = new WeakReference<>(child); + bottomContainerBackHelper = new MaterialBottomContainerBackHelper(child); // Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will // default to android:background declared in styles or layout. if (materialShapeDrawable != null) { @@ -523,9 +553,6 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo // Use elevation attr if set on bottomsheet; otherwise, use elevation of child view. materialShapeDrawable.setElevation( elevation == -1 ? ViewCompat.getElevation(child) : elevation); - // Update the material shape based on initial state. - isShapeExpanded = state == STATE_EXPANDED; - materialShapeDrawable.setInterpolation(isShapeExpanded ? 0f : 1f); } else if (backgroundTint != null) { ViewCompat.setBackgroundTintList(child, backgroundTint); } @@ -549,11 +576,12 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo if (parentHeight - childHeight < insetTop) { if (paddingTopSystemWindowInsets) { // If the bottomsheet would land in the middle of the status bar when fully expanded add - // extra space to make sure it goes all the way. - childHeight = parentHeight; + // extra space to make sure it goes all the way up or up to max height if it is specified. + childHeight = (maxHeight == NO_MAX_SIZE) ? parentHeight : min(parentHeight, maxHeight); } else { // If we don't want the bottomsheet to go under the status bar we cap its height - childHeight = parentHeight - insetTop; + int insetHeight = parentHeight - insetTop; + childHeight = (maxHeight == NO_MAX_SIZE) ? insetHeight : min(insetHeight, maxHeight); } } fitToContentsOffset = max(0, parentHeight - childHeight); @@ -571,6 +599,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } else if (state == STATE_DRAGGING || state == STATE_SETTLING) { ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop()); } + updateDrawableForTargetState(state, /* animate= */ false); nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child)); @@ -640,6 +669,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo && state != STATE_DRAGGING && !parent.isPointInChildBounds(scroll, (int) event.getX(), (int) event.getY()) && viewDragHelper != null + && initialY != INVALID_POSITION && Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop(); } @@ -723,8 +753,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } } else if (dy < 0) { // Downward if (!target.canScrollVertically(-1)) { - // MODIFICATION: Add isHideableWhenDragging method - if (newTop <= collapsedOffset || (hideable && isHideableWhenDragging())) { + if (newTop <= collapsedOffset || canBeHiddenByDragging()) { if (!draggable) { // Prevent dragging return; @@ -778,7 +807,6 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } } } - // MODIFICATION: Add isHideableWhenDragging method } else if (hideable && shouldHide(child, getYVelocity()) && isHideableWhenDragging()) { targetState = STATE_HIDDEN; } else if (lastNestedScrollDy == 0) { @@ -888,6 +916,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents. setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state); + updateDrawableForTargetState(state, /* animate= */ true); updateAccessibilityActions(); } @@ -897,7 +926,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * be adjusted as expected. * * @param maxWidth The maximum width in pixels to be set - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_android_maxWidth * @see #getMaxWidth() */ public void setMaxWidth(@Px int maxWidth) { @@ -907,7 +936,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo /** * Returns the bottom sheet's maximum width, or -1 if no maximum width is set. * - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_android_maxWidth * @see #setMaxWidth(int) */ @Px @@ -920,7 +949,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * BottomSheetDialog#show()} in order for the height to be adjusted as expected. * * @param maxHeight The maximum height in pixels to be set - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_android_maxHeight * @see #getMaxHeight() */ public void setMaxHeight(@Px int maxHeight) { @@ -930,7 +959,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo /** * Returns the bottom sheet's maximum height, or -1 if no maximum height is set. * - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_android_maxHeight * @see #setMaxHeight(int) */ @Px @@ -944,7 +973,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_peekHeight */ public void setPeekHeight(int peekHeight) { setPeekHeight(peekHeight, false); @@ -958,7 +987,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline. * @param animate Whether to animate between the old height and the new height. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_peekHeight */ public final void setPeekHeight(int peekHeight, boolean animate) { boolean layout = false; @@ -1001,7 +1030,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * @return The height of the collapsed bottom sheet in pixels, or {@link #PEEK_HEIGHT_AUTO} if the * sheet is configured to peek automatically at 16:9 ratio keyline * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_peekHeight */ public int getPeekHeight() { return peekHeightAuto ? PEEK_HEIGHT_AUTO : peekHeight; @@ -1015,7 +1044,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * * @param ratio a float between 0 and 1, representing the {@link #STATE_HALF_EXPANDED} ratio. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_halfExpandedRatio */ public void setHalfExpandedRatio( @FloatRange(from = 0.0f, to = 1.0f, fromInclusive = false, toInclusive = false) float ratio) { @@ -1035,7 +1064,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * Gets the ratio for the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. * * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_halfExpandedRatio */ @FloatRange(from = 0.0f, to = 1.0f) public float getHalfExpandedRatio() { @@ -1050,13 +1079,14 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * @param offset an integer value greater than equal to 0, representing the {@link * #STATE_EXPANDED} offset. Value must not exceed the offset in the half expanded state. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_expandedOffset */ public void setExpandedOffset(int offset) { if (offset < 0) { throw new IllegalArgumentException("offset must be greater than or equal to 0"); } this.expandedOffset = offset; + updateDrawableForTargetState(state, /* animate= */ true); } /** @@ -1064,7 +1094,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * pick the offset depending on the height of the content. * * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_expandedOffset */ public int getExpandedOffset() { return fitToContents @@ -1072,8 +1102,6 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo : Math.max(expandedOffset, paddingTopSystemWindowInsets ? 0 : insetTop); } - // MODIFICATION: Add calculateSlideOffset method - /** * Calculates the current offset of the bottom sheet. * @@ -1082,26 +1110,21 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * @return The offset of this bottom sheet within [-1,1] range. Offset increases * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and * expanded states and from -1 to 0 it is between hidden and collapsed states. Returns - * {@code Float.MIN_VALUE} if the bottom sheet is not laid out. + * -1 if the bottom sheet is not laid out (therefore it's hidden). */ public float calculateSlideOffset() { - if (viewRef == null) { - return Float.MIN_VALUE; + if (viewRef == null || viewRef.get() == null) { + return -1; } - View bottomSheet = viewRef.get(); - if (bottomSheet != null) { - return calculateSlideOffset(bottomSheet.getTop()); - } - - return Float.MIN_VALUE; + return calculateSlideOffsetWithTop(viewRef.get().getTop()); } /** - * Sets whether this bottom sheet can hide when it is swiped down. + * Sets whether this bottom sheet can hide. * * @param hideable {@code true} to make this bottom sheet hideable. - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_hideable */ public void setHideable(boolean hideable) { if (this.hideable != hideable) { @@ -1118,7 +1141,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * Gets whether this bottom sheet can hide when it is swiped down. * * @return {@code true} if this bottom sheet can hide. - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_hideable */ public boolean isHideable() { return hideable; @@ -1130,7 +1153,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * * @param skipCollapsed True if the bottom sheet should skip the collapsed state. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_skipCollapsed */ public void setSkipCollapsed(boolean skipCollapsed) { this.skipCollapsed = skipCollapsed; @@ -1142,7 +1165,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * * @return Whether the bottom sheet should skip the collapsed state. * @attr ref - * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed + * com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_skipCollapsed */ public boolean getSkipCollapsed() { return skipCollapsed; @@ -1153,7 +1176,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * dragging, an app will require to implement a custom way to expand/collapse the bottom sheet * * @param draggable {@code false} to prevent dragging the sheet to collapse and expand - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_draggable */ public void setDraggable(boolean draggable) { this.draggable = draggable; @@ -1163,13 +1186,35 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo return draggable; } + /* + * Sets the velocity threshold considered significant enough to trigger a slide + * to the next stable state. + * + * @param significantVelocityThreshold The velocity threshold that warrants a vertical swipe. + * @see #getSignificantVelocityThreshold() + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_significantVelocityThreshold + */ + public void setSignificantVelocityThreshold(int significantVelocityThreshold) { + this.significantVelocityThreshold = significantVelocityThreshold; + } + + /* + * Returns the significant velocity threshold. + * + * @see #setSignificantVelocityThreshold(int) + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_significantVelocityThreshold + */ + public int getSignificantVelocityThreshold() { + return this.significantVelocityThreshold; + } + /** * Sets save flags to be preserved in bottomsheet on configuration change. * * @param flags bitwise int of {@link #SAVE_PEEK_HEIGHT}, {@link #SAVE_FIT_TO_CONTENTS}, {@link * #SAVE_HIDEABLE}, {@link #SAVE_SKIP_COLLAPSED}, {@link #SAVE_ALL} and {@link #SAVE_NONE}. * @see #getSaveFlags() - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_saveFlags */ public void setSaveFlags(@SaveFlags int flags) { this.saveFlags = flags; @@ -1178,7 +1223,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo * Returns the save flags. * * @see #setSaveFlags(int) - * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags + * @attr ref com.google.android.material.R.styleable#BackportBottomSheetBehavior_Layout_behavior_saveFlags */ @SaveFlags public int getSaveFlags() { @@ -1208,9 +1253,9 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } /** - * Sets a listener to be notified of bottom sheet events. + * Sets a callback to be notified of bottom sheet events. * - * @param callback The listener to notify when bottom sheet events occur. + * @param callback The callback to notify when bottom sheet events occur. * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link * #removeBottomSheetCallback(BottomSheetCallback)} instead */ @@ -1218,7 +1263,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo public void setBottomSheetCallback(BottomSheetCallback callback) { Log.w( TAG, - "BottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` removes" + "BackportBottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` removes" + " all existing callbacks, including ones set internally by library authors, which" + " may result in unintended behavior. This may change in the future. Please use" + " `addBottomSheetCallback()` and `removeBottomSheetCallback()` instead to set your" @@ -1230,9 +1275,9 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } /** - * Adds a listener to be notified of bottom sheet events. + * Adds a callback to be notified of bottom sheet events. * - * @param callback The listener to notify when bottom sheet events occur. + * @param callback The callback to notify when bottom sheet events occur. */ public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) { if (!callbacks.contains(callback)) { @@ -1241,9 +1286,9 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } /** - * Removes a previously added listener. + * Removes a previously added callback. * - * @param callback The listener to remove. + * @param callback The callback to remove. */ public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) { callbacks.remove(callback); @@ -1325,6 +1370,26 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo return gestureInsetBottomIgnored; } + /** + * Sets whether the bottom sheet should remove its corners when it reaches the expanded state. + * + *

If false, the bottom sheet will only remove its corners if it is expanded and reaches the + * top of the screen. + */ + public void setShouldRemoveExpandedCorners(boolean shouldRemoveExpandedCorners) { + if (this.shouldRemoveExpandedCorners != shouldRemoveExpandedCorners) { + this.shouldRemoveExpandedCorners = shouldRemoveExpandedCorners; + updateDrawableForTargetState(getState(), /* animate= */ true); + } + } + + /** + * Returns whether the bottom sheet will remove its corners when it reaches the expanded state. + */ + public boolean isShouldRemoveExpandedCorners() { + return shouldRemoveExpandedCorners; + } + /** * Gets the current state of the bottom sheet. * @@ -1376,33 +1441,91 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo updateImportantForAccessibility(false); } - updateDrawableForTargetState(state); + updateDrawableForTargetState(state, /* animate= */ true); for (int i = 0; i < callbacks.size(); i++) { callbacks.get(i).onStateChanged(bottomSheet, state); } updateAccessibilityActions(); } - private void updateDrawableForTargetState(@State int state) { + private void updateDrawableForTargetState(@State int state, boolean animate) { if (state == STATE_SETTLING) { // Special case: we want to know which state we're settling to, so wait for another call. return; } - boolean expand = state == STATE_EXPANDED; - if (isShapeExpanded != expand) { - isShapeExpanded = expand; - if (materialShapeDrawable != null && interpolatorAnimator != null) { - if (interpolatorAnimator.isRunning()) { - interpolatorAnimator.reverse(); - } else { - float to = expand ? 0f : 1f; - float from = 1f - to; - interpolatorAnimator.setFloatValues(from, to); - interpolatorAnimator.start(); + boolean removeCorners = isExpandedAndShouldRemoveCorners(); + if (expandedCornersRemoved == removeCorners || materialShapeDrawable == null) { + return; + } + expandedCornersRemoved = removeCorners; + if (animate && interpolatorAnimator != null) { + if (interpolatorAnimator.isRunning()) { + interpolatorAnimator.reverse(); + } else { + float to = removeCorners ? calculateInterpolationWithCornersRemoved() : 1f; + float from = 1f - to; + interpolatorAnimator.setFloatValues(from, to); + interpolatorAnimator.start(); + } + } else { + if (interpolatorAnimator != null && interpolatorAnimator.isRunning()) { + interpolatorAnimator.cancel(); + } + materialShapeDrawable.setInterpolation( + expandedCornersRemoved ? calculateInterpolationWithCornersRemoved() : 1f); + } + } + + private float calculateInterpolationWithCornersRemoved() { + if (materialShapeDrawable != null + && viewRef != null + && viewRef.get() != null + && VERSION.SDK_INT >= VERSION_CODES.S) { + V view = viewRef.get(); + // Only use device corner radius if sheet is touching top of screen. + if (isAtTopOfScreen()) { + final WindowInsets insets = view.getRootWindowInsets(); + if (insets != null) { + float topLeftInterpolation = + calculateCornerInterpolation( + materialShapeDrawable.getTopLeftCornerResolvedSize(), + insets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)); + float topRightInterpolation = + calculateCornerInterpolation( + materialShapeDrawable.getTopRightCornerResolvedSize(), + insets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)); + return Math.max(topLeftInterpolation, topRightInterpolation); } } } + return 0; + } + + @RequiresApi(VERSION_CODES.S) + private float calculateCornerInterpolation( + float materialShapeDrawableCornerSize, @Nullable RoundedCorner deviceRoundedCorner) { + if (deviceRoundedCorner != null) { + float deviceCornerRadius = deviceRoundedCorner.getRadius(); + if (deviceCornerRadius > 0 && materialShapeDrawableCornerSize > 0) { + return deviceCornerRadius / materialShapeDrawableCornerSize; + } + } + return 0; + } + + private boolean isAtTopOfScreen() { + if (viewRef == null || viewRef.get() == null) { + return false; + } + int[] location = new int[2]; + viewRef.get().getLocationOnScreen(location); + return location[1] == 0; + } + + private boolean isExpandedAndShouldRemoveCorners() { + // Only remove corners when it's full screen. + return state == STATE_EXPANDED && (shouldRemoveExpandedCorners || isAtTopOfScreen()); } private int calculatePeekHeight() { @@ -1432,9 +1555,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio)); } - // MODIFICATION: Add calculateSlideOffset method - - private float calculateSlideOffset(int top) { + private float calculateSlideOffsetWithTop(int top) { return (top > collapsedOffset || collapsedOffset == getExpandedOffset()) ? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset) @@ -1443,6 +1564,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo private void reset() { activePointerId = ViewDragHelper.INVALID_POINTER; + initialY = INVALID_POSITION; if (velocityTracker != null) { velocityTracker.recycle(); velocityTracker = null; @@ -1473,6 +1595,9 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo if (skipCollapsed) { return true; } + if (!isHideableWhenDragging()) { + return false; + } if (child.getTop() < collapsedOffset) { // It should not hide, but collapse. return false; @@ -1482,9 +1607,73 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo return Math.abs(newTop - collapsedOffset) / (float) peek > HIDE_THRESHOLD; } + @Override + public void startBackProgress(@NonNull BackEventCompat backEvent) { + if (bottomContainerBackHelper == null) { + return; + } + bottomContainerBackHelper.startBackProgress(backEvent); + } + + @Override + public void updateBackProgress(@NonNull BackEventCompat backEvent) { + if (bottomContainerBackHelper == null) { + return; + } + bottomContainerBackHelper.updateBackProgress(backEvent); + } + + @Override + public void handleBackInvoked() { + if (bottomContainerBackHelper == null) { + return; + } + BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked(); + if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { + // If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet. + setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED); + return; + } + if (hideable) { + bottomContainerBackHelper.finishBackProgressNotPersistent( + backEvent, + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // Hide immediately following the built-in predictive back slide down animation. + setStateInternal(STATE_HIDDEN); + if (viewRef != null && viewRef.get() != null) { + viewRef.get().requestLayout(); + } + } + }); + } else { + bottomContainerBackHelper.finishBackProgressPersistent( + backEvent, /* animatorListener= */ null); + setState(STATE_COLLAPSED); + } + } + + @Override + public void cancelBackProgress() { + if (bottomContainerBackHelper == null) { + return; + } + bottomContainerBackHelper.cancelBackProgress(); + } + + @VisibleForTesting + @Nullable + MaterialBottomContainerBackHelper getBackHelper() { + return bottomContainerBackHelper; + } + @Nullable @VisibleForTesting View findScrollingChild(View view) { + if (view.getVisibility() != View.VISIBLE) { + return null; + } if (ViewCompat.isNestedScrollingEnabled(view)) { return view; } @@ -1524,12 +1713,12 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } } - MaterialShapeDrawable getMaterialShapeDrawable() { + protected MaterialShapeDrawable getMaterialShapeDrawable() { return materialShapeDrawable; } private void createShapeValueAnimator() { - interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f); + interpolatorAnimator = ValueAnimator.ofFloat(calculateInterpolationWithCornersRemoved(), 1f); interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION); interpolatorAnimator.addUpdateListener( new AnimatorUpdateListener() { @@ -1650,7 +1839,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo if (settling) { setStateInternal(STATE_SETTLING); // STATE_SETTLING won't animate the material shape, so do that here with the target state. - updateDrawableForTargetState(state); + updateDrawableForTargetState(state, /* animate= */ true); stateSettlingTracker.continueSettlingToState(state); } else { setStateInternal(state); @@ -1741,11 +1930,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } } } - // MODIFICATION: Add isHideableWhenDragging method - } else if (hideable && shouldHide(releasedChild, yvel) && isHideableWhenDragging()) { + } else if (hideable && shouldHide(releasedChild, yvel)) { // Hide if the view was either released low or it was a significant vertical swipe // otherwise settle to closest expanded state. - if ((Math.abs(xvel) < Math.abs(yvel) && yvel > SIGNIFICANT_VEL_THRESHOLD) + if ((Math.abs(xvel) < Math.abs(yvel) && yvel > significantVelocityThreshold) || releasedLow(releasedChild)) { targetState = STATE_HIDDEN; } else if (fitToContents) { @@ -1814,9 +2002,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo @Override public int clampViewPositionVertical(@NonNull View child, int top, int dy) { - // MODIFICATION: Add isHideableWhenDragging method return MathUtils.clamp( - top, getExpandedOffset(), (hideable && isHideableWhenDragging()) ? parentHeight : collapsedOffset); + top, + getExpandedOffset(), + getViewVerticalDragRange(child)); } @Override @@ -1826,8 +2015,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo @Override public int getViewVerticalDragRange(@NonNull View child) { - // MODIFICATION: Add isHideableWhenDragging method - if (hideable && isHideableWhenDragging()) { + if (canBeHiddenByDragging()) { return parentHeight; } else { return collapsedOffset; @@ -1838,8 +2026,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo void dispatchOnSlide(int top) { View bottomSheet = viewRef.get(); if (bottomSheet != null && !callbacks.isEmpty()) { - // MODIFICATION: Add calculateSlideOffset method - float slideOffset = calculateSlideOffset(top); + float slideOffset = calculateSlideOffsetWithTop(top); for (int i = 0; i < callbacks.size(); i++) { callbacks.get(i).onSlide(bottomSheet, slideOffset); } @@ -1898,7 +2085,8 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } /** - * Checks whether hiding gestures should be enabled if {@code isHideable} is true. + * Checks whether hiding gestures should be enabled while {@code isHideable} is set to true. + * * @hide */ @RestrictTo(LIBRARY_GROUP) @@ -1906,6 +2094,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo return true; } + private boolean canBeHiddenByDragging() { + return isHideable() && isHideableWhenDragging(); + } + /** * Checks whether the bottom sheet should be expanded after it has been released after dragging. * @@ -2067,7 +2259,7 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) params).getBehavior(); if (!(behavior instanceof BackportBottomSheetBehavior)) { - throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior"); + throw new IllegalArgumentException("The view is not associated with BackportBottomSheetBehavior"); } return (BackportBottomSheetBehavior) behavior; } @@ -2139,30 +2331,43 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo } } + void setAccessibilityDelegateView(@Nullable View accessibilityDelegateView) { + if (accessibilityDelegateView == null && accessibilityDelegateViewRef != null) { + clearAccessibilityAction( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + accessibilityDelegateViewRef = null; + return; + } + accessibilityDelegateViewRef = new WeakReference<>(accessibilityDelegateView); + updateAccessibilityActions(accessibilityDelegateView, VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + } + private void updateAccessibilityActions() { - if (viewRef == null) { - return; + if (viewRef != null) { + updateAccessibilityActions(viewRef.get(), VIEW_INDEX_BOTTOM_SHEET); } - V child = viewRef.get(); - if (child == null) { - return; + if (accessibilityDelegateViewRef != null) { + updateAccessibilityActions( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); } - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS); + } - if (expandHalfwayActionId != View.NO_ID) { - ViewCompat.removeAccessibilityAction(child, expandHalfwayActionId); + private void updateAccessibilityActions(View view, int viewIndex) { + if (view == null) { + return; } + clearAccessibilityAction(view, viewIndex); + if (!fitToContents && state != STATE_HALF_EXPANDED) { - expandHalfwayActionId = + expandHalfwayActionIds.put( + viewIndex, addAccessibilityActionForState( - child, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED); + view, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED)); } - if (hideable && state != STATE_HIDDEN) { + if ((hideable && isHideableWhenDragging()) && state != STATE_HIDDEN) { replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); + view, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); } switch (state) { @@ -2170,36 +2375,54 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo { int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED; replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); + view, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); break; } case STATE_HALF_EXPANDED: { replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); + view, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); + view, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); break; } case STATE_COLLAPSED: { int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED; replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, nextState); + view, AccessibilityActionCompat.ACTION_EXPAND, nextState); break; } - default: // fall out + case STATE_HIDDEN: + case STATE_DRAGGING: + case STATE_SETTLING: + // Accessibility actions are not applicable, do nothing + } + } + + private void clearAccessibilityAction(View view, int viewIndex) { + if (view == null) { + return; + } + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_EXPAND); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_DISMISS); + + int expandHalfwayActionId = expandHalfwayActionIds.get(viewIndex, View.NO_ID); + if (expandHalfwayActionId != View.NO_ID) { + ViewCompat.removeAccessibilityAction(view, expandHalfwayActionId); + expandHalfwayActionIds.delete(viewIndex); } } private void replaceAccessibilityActionForState( - V child, AccessibilityActionCompat action, @State int state) { + View child, AccessibilityActionCompat action, @State int state) { ViewCompat.replaceAccessibilityAction( child, action, null, createAccessibilityViewCommandForState(state)); } private int addAccessibilityActionForState( - V child, @StringRes int stringResId, @State int state) { + View child, @StringRes int stringResId, @State int state) { return ViewCompat.addAccessibilityAction( child, child.getResources().getString(stringResId), @@ -2216,4 +2439,3 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo }; } } - diff --git a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java deleted file mode 100644 index 26de5108b..000000000 --- a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java +++ /dev/null @@ -1,413 +0,0 @@ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.android.material.divider; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.ShapeDrawable; -import android.util.AttributeSet; -import android.view.View; -import android.widget.LinearLayout; - -import androidx.annotation.ColorInt; -import androidx.annotation.ColorRes; -import androidx.annotation.DimenRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.Px; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ItemDecoration; - -import com.google.android.material.R; -import com.google.android.material.internal.ThemeEnforcement; -import com.google.android.material.resources.MaterialResources; - -/** - * MaterialDividerItemDecoration is a {@link RecyclerView.ItemDecoration}, similar to a {@link - * androidx.recyclerview.widget.DividerItemDecoration}, that can be used as a divider between items of - * a {@link LinearLayoutManager}. It supports both {@link #HORIZONTAL} and {@link #VERTICAL} - * orientations. - * - *

- *     dividerItemDecoration = new MaterialDividerItemDecoration(recyclerView.getContext(),
- *             layoutManager.getOrientation());
- *     recyclerView.addItemDecoration(dividerItemDecoration);
- * 
- * - * Modified at several points by Alexander Capehart to backport miscellaneous fixes not currently - * obtainable in the currently used MDC library. - */ -public class BackportMaterialDividerItemDecoration extends ItemDecoration { - public static final int HORIZONTAL = LinearLayout.HORIZONTAL; - public static final int VERTICAL = LinearLayout.VERTICAL; - - private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_MaterialDivider; - - @NonNull private Drawable dividerDrawable; - private int thickness; - @ColorInt private int color; - private int orientation; - private int insetStart; - private int insetEnd; - private boolean lastItemDecorated; - - private final Rect tempRect = new Rect(); - - public BackportMaterialDividerItemDecoration(@NonNull Context context, int orientation) { - this(context, null, orientation); - } - - public BackportMaterialDividerItemDecoration( - @NonNull Context context, @Nullable AttributeSet attrs, int orientation) { - this(context, attrs, R.attr.materialDividerStyle, orientation); - } - - public BackportMaterialDividerItemDecoration( - @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int orientation) { - TypedArray attributes = - ThemeEnforcement.obtainStyledAttributes( - context, attrs, R.styleable.MaterialDivider, defStyleAttr, DEF_STYLE_RES); - - color = - MaterialResources.getColorStateList( - context, attributes, R.styleable.MaterialDivider_dividerColor) - .getDefaultColor(); - thickness = - attributes.getDimensionPixelSize( - R.styleable.MaterialDivider_dividerThickness, - context.getResources().getDimensionPixelSize(R.dimen.material_divider_thickness)); - insetStart = - attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetStart, 0); - insetEnd = attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetEnd, 0); - lastItemDecorated = - attributes.getBoolean(R.styleable.MaterialDivider_lastItemDecorated, true); - - attributes.recycle(); - - dividerDrawable = new ShapeDrawable(); - setDividerColor(color); - setOrientation(orientation); - } - - /** - * Sets the orientation for this divider. This should be called if {@link - * RecyclerView.LayoutManager} changes orientation. - * - *

A {@link #HORIZONTAL} orientation will draw a vertical divider, and a {@link #VERTICAL} - * orientation a horizontal divider. - * - * @param orientation The orientation of the {@link RecyclerView} this divider is associated with: - * {@link #HORIZONTAL} or {@link #VERTICAL} - */ - public void setOrientation(int orientation) { - if (orientation != HORIZONTAL && orientation != VERTICAL) { - throw new IllegalArgumentException( - "Invalid orientation: " + orientation + ". It should be either HORIZONTAL or VERTICAL"); - } - this.orientation = orientation; - } - - public int getOrientation() { - return orientation; - } - - /** - * Sets the thickness of the divider. - * - * @param thickness The thickness value to be set. - * @see #getDividerThickness() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness - */ - public void setDividerThickness(@Px int thickness) { - this.thickness = thickness; - } - - /** - * Sets the thickness of the divider. - * - * @param thicknessId The id of the thickness dimension resource to be set. - * @see #getDividerThickness() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness - */ - public void setDividerThicknessResource(@NonNull Context context, @DimenRes int thicknessId) { - setDividerThickness(context.getResources().getDimensionPixelSize(thicknessId)); - } - - /** - * Returns the thickness set on the divider. - * - * @see #setDividerThickness(int) - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness - */ - @Px - public int getDividerThickness() { - return thickness; - } - - /** - * Sets the color of the divider. - * - * @param color The color to be set. - * @see #getDividerColor() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor - */ - public void setDividerColor(@ColorInt int color) { - this.color = color; - dividerDrawable = DrawableCompat.wrap(dividerDrawable); - DrawableCompat.setTint(dividerDrawable, color); - } - - /** - * Sets the color of the divider. - * - * @param colorId The id of the color resource to be set. - * @see #getDividerColor() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor - */ - public void setDividerColorResource(@NonNull Context context, @ColorRes int colorId) { - setDividerColor(ContextCompat.getColor(context, colorId)); - } - - /** - * Returns the divider color. - * - * @see #setDividerColor(int) - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor - */ - @ColorInt - public int getDividerColor() { - return color; - } - - /** - * Sets the start inset of the divider. - * - * @param insetStart The start inset to be set. - * @see #getDividerInsetStart() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart - */ - public void setDividerInsetStart(@Px int insetStart) { - this.insetStart = insetStart; - } - - /** - * Sets the start inset of the divider. - * - * @param insetStartId The id of the inset dimension resource to be set. - * @see #getDividerInsetStart() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart - */ - public void setDividerInsetStartResource(@NonNull Context context, @DimenRes int insetStartId) { - setDividerInsetStart(context.getResources().getDimensionPixelOffset(insetStartId)); - } - - /** - * Returns the divider's start inset. - * - * @see #setDividerInsetStart(int) - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart - */ - @Px - public int getDividerInsetStart() { - return insetStart; - } - - /** - * Sets the end inset of the divider. - * - * @param insetEnd The end inset to be set. - * @see #getDividerInsetEnd() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd - */ - public void setDividerInsetEnd(@Px int insetEnd) { - this.insetEnd = insetEnd; - } - - /** - * Sets the end inset of the divider. - * - * @param insetEndId The id of the inset dimension resource to be set. - * @see #getDividerInsetEnd() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd - */ - public void setDividerInsetEndResource(@NonNull Context context, @DimenRes int insetEndId) { - setDividerInsetEnd(context.getResources().getDimensionPixelOffset(insetEndId)); - } - - /** - * Returns the divider's end inset. - * - * @see #setDividerInsetEnd(int) - * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd - */ - @Px - public int getDividerInsetEnd() { - return insetEnd; - } - - /** - * Sets whether the class should draw a divider after the last item of a {@link RecyclerView}. - * - * @param lastItemDecorated whether there's a divider after the last item of a recycler view. - * @see #isLastItemDecorated() - * @attr ref com.google.android.material.R.styleable#MaterialDivider_lastItemDecorated - */ - public void setLastItemDecorated(boolean lastItemDecorated) { - this.lastItemDecorated = lastItemDecorated; - } - - /** - * Whether there's a divider after the last item of a {@link RecyclerView}. - * - * @see #setLastItemDecorated(boolean) - * @attr ref com.google.android.material.R.styleable#MaterialDivider_shouldDecorateLastItem - */ - public boolean isLastItemDecorated() { - return lastItemDecorated; - } - - @Override - public void onDraw( - @NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - if (parent.getLayoutManager() == null) { - return; - } - if (orientation == VERTICAL) { - drawForVerticalOrientation(canvas, parent); - } else { - drawForHorizontalOrientation(canvas, parent); - } - } - - /** - * Draws a divider for the vertical orientation of the recycler view. The divider itself will be - * horizontal. - */ - private void drawForVerticalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { - canvas.save(); - int left; - int right; - if (parent.getClipToPadding()) { - left = parent.getPaddingLeft(); - right = parent.getWidth() - parent.getPaddingRight(); - canvas.clipRect( - left, parent.getPaddingTop(), right, parent.getHeight() - parent.getPaddingBottom()); - } else { - left = 0; - right = parent.getWidth(); - } - boolean isRtl = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL; - left += isRtl ? insetEnd : insetStart; - right -= isRtl ? insetStart : insetEnd; - - int childCount = parent.getChildCount(); - for (int i = 0; i < childCount; i++) { - View child = parent.getChildAt(i); - if (shouldDrawDivider(parent, child)) { - parent.getDecoratedBoundsWithMargins(child, tempRect); - // Take into consideration any translationY added to the view. - int bottom = tempRect.bottom + Math.round(child.getTranslationY()); - int top = bottom - thickness; - dividerDrawable.setBounds(left, top, right, bottom); - dividerDrawable.draw(canvas); - } - } - canvas.restore(); - } - - /** - * Draws a divider for the horizontal orientation of the recycler view. The divider itself will be - * vertical. - */ - private void drawForHorizontalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { - canvas.save(); - int top; - int bottom; - if (parent.getClipToPadding()) { - top = parent.getPaddingTop(); - bottom = parent.getHeight() - parent.getPaddingBottom(); - canvas.clipRect( - parent.getPaddingLeft(), top, parent.getWidth() - parent.getPaddingRight(), bottom); - } else { - top = 0; - bottom = parent.getHeight(); - } - top += insetStart; - bottom -= insetEnd; - - int childCount = parent.getChildCount(); - for (int i = 0; i < childCount; i++) { - View child = parent.getChildAt(i); - if (shouldDrawDivider(parent, child)) { - parent.getDecoratedBoundsWithMargins(child, tempRect); - // Take into consideration any translationX added to the view. - int right = tempRect.right + Math.round(child.getTranslationX()); - int left = right - thickness; - dividerDrawable.setBounds(left, top, right, bottom); - dividerDrawable.draw(canvas); - } - } - canvas.restore(); - } - - @Override - public void getItemOffsets( - @NonNull Rect outRect, - @NonNull View view, - @NonNull RecyclerView parent, - @NonNull RecyclerView.State state) { - outRect.set(0, 0, 0, 0); - // Only add offset if there's a divider displayed. - if (shouldDrawDivider(parent, view)) { - if (orientation == VERTICAL) { - outRect.bottom = thickness; - } else { - outRect.right = thickness; - } - } - } - - private boolean shouldDrawDivider(@NonNull RecyclerView parent, @NonNull View child) { - int position = parent.getChildAdapterPosition(child); - RecyclerView.Adapter adapter = parent.getAdapter(); - boolean isLastItem = adapter != null && position == adapter.getItemCount() - 1; - - return position != RecyclerView.NO_POSITION - && (!isLastItem || lastItemDecorated) - && shouldDrawDivider(position, adapter); - } - - /** - * Whether a divider should be drawn below the current item that is being drawn. - * - *

Note: if lasItemDecorated is false, the divider below the last item will never be drawn even - * if this method returns true. - * - * @param position the position of the current item being drawn. - * @param adapter the {@link RecyclerView.Adapter} associated with the item being drawn. - */ - protected boolean shouldDrawDivider(int position, @Nullable RecyclerView.Adapter adapter) { - return true; - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index b2912595c..1ec38068e 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -48,6 +48,8 @@ import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.extractor.RoundedRectTransformation +import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -77,6 +79,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr FrameLayout(context, attrs, defStyleAttr) { @Inject lateinit var imageLoader: ImageLoader @Inject lateinit var uiSettings: UISettings + @Inject lateinit var imageSettings: ImageSettings private val image: ImageView @@ -384,13 +387,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr ImageRequest.Builder(context) .data(songs) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) - .transformations( - RoundedCornersTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)) .target(image) - .build() + + val cornersTransformation = + RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f) + if (imageSettings.forceSquareCovers) { + request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation) + } else { + request.transformations(cornersTransformation) + } + // Dispose of any previous image request and load a new image. CoilUtils.dispose(image) - imageLoader.enqueue(request) + imageLoader.enqueue(request.build()) contentDescription = desc } diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt index a4c13c4c9..1a9a01b24 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -34,6 +34,8 @@ import org.oxycblt.auxio.util.logD interface ImageSettings : Settings { /** The strategy to use when loading album covers. */ val coverMode: CoverMode + /** Whether to force all album covers to have a 1:1 aspect ratio. */ + val forceSquareCovers: Boolean interface Listener { /** Called when [coverMode] changes. */ @@ -49,6 +51,9 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) ?: CoverMode.MEDIA_STORE + override val forceSquareCovers: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false) + override fun migrate() { // Show album covers and Ignore MediaStore covers were unified in 3.0.0 if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) || diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 6ca126256..537d8e874 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -43,7 +43,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.io.ByteArrayInputStream import java.io.InputStream import javax.inject.Inject -import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.asDeferred import kotlinx.coroutines.withContext @@ -155,7 +154,7 @@ constructor( // Get the embedded picture from MediaMetadataRetriever, which will return a full // ByteArray of the cover without any compression artifacts. // If its null [i.e there is no embedded cover], than just ignore it and move on - return embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } + embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } } private suspend fun extractExoplayerCover(album: Album): InputStream? { @@ -212,7 +211,7 @@ constructor( } /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ - private fun createMosaic(streams: List, size: Size): FetchResult { + private suspend fun createMosaic(streams: List, size: Size): FetchResult { // Use whatever size coil gives us to create the mosaic. val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize()) val mosaicFrameSize = @@ -234,7 +233,9 @@ constructor( // Crop the bitmap down to a square so it leaves no empty space // TODO: Work around this - val bitmap = cropBitmap(BitmapFactory.decodeStream(stream), mosaicFrameSize) + val bitmap = + SquareCropTransformation.INSTANCE.transform( + BitmapFactory.decodeStream(stream), mosaicFrameSize) canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) x += bitmap.width @@ -259,21 +260,4 @@ constructor( val size = pxOrElse { 512 } return if (size.mod(2) > 0) size + 1 else size } - - private fun cropBitmap(input: Bitmap, size: Size): Bitmap { - // Find the smaller dimension and then take a center portion of the image that - // has that size. - val dstSize = min(input.width, input.height) - val x = (input.width - dstSize) / 2 - val y = (input.height - dstSize) / 2 - val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) - - val desiredWidth = size.width.pxOrElse { dstSize } - val desiredHeight = size.height.pxOrElse { dstSize } - if (dstSize != desiredWidth || dstSize != desiredHeight) { - // Image is not the desired size, upscale it. - return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) - } - return dst - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt index c25770ec4..c8d3ee145 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/RoundedCornersTransformation.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/RoundedRectTransformation.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * RoundedCornersTransformation.kt is part of Auxio. + * RoundedRectTransformation.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 @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.image +package org.oxycblt.auxio.image.extractor import android.graphics.Bitmap import android.graphics.Bitmap.createBitmap @@ -43,7 +43,7 @@ import kotlin.math.roundToInt * * @author Coil Team, Alexander Capehart (OxygenCobalt) */ -class RoundedCornersTransformation( +class RoundedRectTransformation( @Px private val topLeft: Float = 0f, @Px private val topRight: Float = 0f, @Px private val bottomLeft: Float = 0f, @@ -122,7 +122,7 @@ class RoundedCornersTransformation( override fun equals(other: Any?): Boolean { if (this === other) return true - return other is RoundedCornersTransformation && + return other is RoundedRectTransformation && topLeft == other.topLeft && topRight == other.topRight && bottomLeft == other.bottomLeft && diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareCropTransformation.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareCropTransformation.kt new file mode 100644 index 000000000..57f03dbef --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareCropTransformation.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 Auxio Project + * SquareCropTransformation.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.image.extractor + +import android.graphics.Bitmap +import coil.size.Size +import coil.size.pxOrElse +import coil.transform.Transformation +import kotlin.math.min + +/** + * A [Transformation] that performs a center crop-style transformation on an image. Allowing this + * behavior to be intrinsic without any view configuration. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class SquareCropTransformation : Transformation { + override val cacheKey: String + get() = "SquareCropTransformation" + + override suspend fun transform(input: Bitmap, size: Size): Bitmap { + // Find the smaller dimension and then take a center portion of the image that + // has that size. + val dstSize = min(input.width, input.height) + val x = (input.width - dstSize) / 2 + val y = (input.height - dstSize) / 2 + val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize) + + val desiredWidth = size.width.pxOrElse { dstSize } + val desiredHeight = size.height.pxOrElse { dstSize } + if (dstSize != desiredWidth || dstSize != desiredHeight) { + // Image is not the desired size, upscale it. + return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) + } + return dst + } + + companion object { + /** A re-usable instance. */ + val INSTANCE = SquareCropTransformation() + } +} 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 513786f87..6e45c5ae9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -23,8 +23,6 @@ import android.content.pm.PackageManager import androidx.core.content.ContextCompat import java.util.LinkedList import javax.inject.Inject -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -339,40 +337,56 @@ constructor( } override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = - worker.scope.launch { - try { - val start = System.currentTimeMillis() - indexImpl(worker, withCache) - logD( - "Music indexing completed successfully in " + - "${System.currentTimeMillis() - start}ms") - } catch (e: CancellationException) { - // Got cancelled, propagate upwards to top-level co-routine. - logD("Loading routine was cancelled") - throw e - } catch (e: Exception) { - // Music loading process failed due to something we have not handled. - logE("Music indexing failed") - logE(e.stackTraceToString()) - emitIndexingCompletion(e) - } + worker.scope.launch { indexWrapper(worker, withCache) } + + private suspend fun indexWrapper(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + try { + indexImpl(worker, withCache) + } catch (e: CancellationException) { + // Got cancelled, propagate upwards to top-level co-routine. + logD("Loading routine was cancelled") + throw e + } catch (e: Exception) { + // Music loading process failed due to something we have not handled. + // TODO: Still want to display this error eventually + logE("Music indexing failed") + logE(e.stackTraceToString()) + emitIndexingCompletion(e) } + } private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + val start = System.currentTimeMillis() + // Make sure we have permissions before going forward. Theoretically this would be better + // done at the UI level, but that intertwines logic and display too much. if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { logE("Permissions were not granted") - // No permissions, signal that we can't do anything. throw NoAudioPermissionException() } - // Start initializing the extractors. Use an indeterminate state, as there is no ETA on - // how long a media database query will take. - emitIndexingProgress(IndexingProgress.Indeterminate) - - // Do the initial query of the cache and media databases in parallel. + // Begin with querying MediaStore and the music cache. The former is needed for Auxio + // to figure out what songs are (probably) on the device, and the latter will be needed + // for discovery (described later). These have no shared state, so they are done in + // parallel. logD("Starting MediaStore query") - val mediaStoreQueryJob = worker.scope.tryAsync { mediaStoreExtractor.query() } + emitIndexingProgress(IndexingProgress.Indeterminate) + val mediaStoreQueryJob = + worker.scope.async { + val query = + try { + mediaStoreExtractor.query() + } catch (e: Exception) { + // Normally, errors in an async call immediately bubble up to the Looper + // and crash the app. Thus, we have to wrap any error into a Result + // and then manually forward it to the try block that indexImpl is + // called from. + return@async Result.failure(e) + } + Result.success(query) + } + // Since this main thread is a co-routine, we can do operations in parallel in a way + // identical to calling async. val cache = if (withCache) { logD("Reading cache") @@ -383,59 +397,121 @@ constructor( logD("Awaiting MediaStore query") val query = mediaStoreQueryJob.await().getOrThrow() - // Now start processing the queried song information in parallel. Songs that can't be - // received from the cache are consisted incomplete and pushed to a separate channel - // that will eventually be processed into completed raw songs. - logD("Starting song discovery") - val completeSongs = Channel(Channel.UNLIMITED) - val incompleteSongs = Channel(Channel.UNLIMITED) - val processedSongs = Channel(Channel.UNLIMITED) - logD("Started MediaStore discovery") + // We now have all the information required to start the "discovery" process. This + // is the point at which Auxio starts scanning each file given from MediaStore and + // transforming it into a music library. MediaStore normally + logD("Starting discovery") + val incompleteSongs = Channel(Channel.UNLIMITED) // Not fully populated w/metadata + val completeSongs = Channel(Channel.UNLIMITED) // Populated with quality metadata + val processedSongs = Channel(Channel.UNLIMITED) // Transformed into SongImpl + + // MediaStoreExtractor discovers all music on the device, and forwards them to either + // DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata + // does not exist. In the latter situation, it also applies it's own (inferior) metadata. + logD("Starting MediaStore discovery") val mediaStoreJob = - worker.scope.tryAsync { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + worker.scope.async { + try { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + } catch (e: Exception) { + // 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) + return@async + } incompleteSongs.close() } - logD("Started ExoPlayer discovery") - val metadataJob = - worker.scope.tryAsync { - tagExtractor.consume(incompleteSongs, completeSongs) + + // TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date + // metadata for them, and then forwards it to DeviceLibrary. + logD("Starting tag extraction") + val tagJob = + worker.scope.async { + try { + tagExtractor.consume(incompleteSongs, completeSongs) + } catch (e: Exception) { + completeSongs.close(e) + return@async + } completeSongs.close() } + + // DeviceLibrary constructs music parent instances as song information is provided, + // and then forwards them to the primary loading loop. logD("Starting DeviceLibrary creation") val deviceLibraryJob = - worker.scope.tryAsync(Dispatchers.Default) { - deviceLibraryFactory.create(completeSongs, processedSongs).also { - processedSongs.close() - } + worker.scope.async(Dispatchers.Default) { + val deviceLibrary = + try { + deviceLibraryFactory.create(completeSongs, processedSongs) + } catch (e: Exception) { + processedSongs.close(e) + return@async Result.failure(e) + } + processedSongs.close() + Result.success(deviceLibrary) } - // Await completed raw songs as they are processed. + // We could keep track of a total here, but we also need to collate this RawSong information + // for when we write the cache later on in the finalization step. val rawSongs = LinkedList() for (rawSong in processedSongs) { rawSongs.add(rawSong) + // Since discovery takes up the bulk of the music loading process, we switch to + // indicating a defined amount of loaded songs in comparison to the projected amount + // of songs that were queried. emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) } - logD("Awaiting discovery completion") - // These should be no-ops, but we need the error state to see if we should keep going. - mediaStoreJob.await().getOrThrow() - metadataJob.await().getOrThrow() + // This shouldn't occur, but keep them around just in case there's a regression. + // Note that DeviceLibrary might still actually be doing work (specifically parent + // processing), so we don't check if it's deadlocked. + check(!mediaStoreJob.isActive) { "MediaStore discovery is deadlocked" } + check(!tagJob.isActive) { "Tag extraction is deadlocked" } + + // Deliberately done after the involved initialization step to make it less likely + // that the short-circuit occurs so quickly as to break the UI. + // TODO: Do not error, instead just wipe the entire library. if (rawSongs.isEmpty()) { logE("Music library was empty") throw NoMusicException() } - // Successfully loaded the library, now save the cache and read playlist information - // in parallel. + // Now that the library is effectively loaded, we can start the finalization step, which + // involves writing new cache information and creating more music data that is derived + // from the library (e.g playlists) logD("Discovered ${rawSongs.size} songs, starting finalization") + + // We have no idea how long the cache will take, and the playlist construction + // will be too fast to indicate, so switch back to an indeterminate state. emitIndexingProgress(IndexingProgress.Indeterminate) + + // The UserLibrary job is split into a query and construction step, a la MediaStore. + // This way, we can start working on playlists even as DeviceLibrary might still be + // working on parent information. logD("Starting UserLibrary query") - val userLibraryQueryJob = worker.scope.tryAsync { userLibraryFactory.query() } + val userLibraryQueryJob = + worker.scope.async { + val rawPlaylists = + try { + userLibraryFactory.query() + } catch (e: Exception) { + return@async Result.failure(e) + } + Result.success(rawPlaylists) + } + + // The cache might not exist, or we might have encountered a song not present in it. + // Both situations require us to rewrite the cache in bulk. This is also done parallel + // since the playlist read will probably take some time. + // TODO: Read/write from the cache incrementally instead of in bulk? if (cache == null || cache.invalidated) { logD("Writing cache [why=${cache?.invalidated}]") cacheRepository.writeCache(rawSongs) } + + // Create UserLibrary once we finally get the required components for it. logD("Awaiting UserLibrary query") val rawPlaylists = userLibraryQueryJob.await().getOrThrow() logD("Awaiting DeviceLibrary creation") @@ -443,14 +519,20 @@ constructor( logD("Starting UserLibrary creation") val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) - logD("Successfully indexed music library [device=$deviceLibrary user=$userLibrary]") + // Loading process is functionally done, indicate such + logD( + "Successfully indexed music library [device=$deviceLibrary " + + "user=$userLibrary time=${System.currentTimeMillis() - start}]") emitIndexingCompletion(null) - // Comparing the library instances is obscenely expensive, do it within the library - val deviceLibraryChanged: Boolean val userLibraryChanged: Boolean + // We want to make sure that all reads and writes are synchronized due to the sheer + // amount of consumers of MusicRepository. + // TODO: Would Atomics not be a better fit here? synchronized(this) { + // It's possible that this reload might have changed nothing, so make sure that + // hasn't happened before dispatching a change to all consumers. deviceLibraryChanged = this.deviceLibrary != deviceLibrary userLibraryChanged = this.userLibrary != userLibrary if (!deviceLibraryChanged && !userLibraryChanged) { @@ -462,27 +544,13 @@ constructor( this.userLibrary = userLibrary } + // Consumers expect their updates to be on the main thread (notably PlaybackService), + // so switch to it. withContext(Dispatchers.Main) { dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged) } } - /** - * An extension of [async] that forces the outcome to a [Result] to allow exceptions to bubble - * upwards instead of crashing the entire app. - */ - private inline fun CoroutineScope.tryAsync( - context: CoroutineContext = EmptyCoroutineContext, - crossinline block: suspend () -> R - ) = - async(context) { - try { - Result.success(block()) - } catch (e: Exception) { - Result.failure(e) - } - } - private suspend fun emitIndexingProgress(progress: IndexingProgress) { yield() synchronized(this) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 9cb4d70b5..354f6520f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -151,6 +151,7 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son rawArtists = rawAlbumArtists .ifEmpty { rawIndividualArtists } + .distinctBy { it.key } .ifEmpty { listOf(RawArtist(null, null)) }) /** @@ -159,7 +160,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son * [RawArtist]. This can be used to group up [Song]s into an [Artist]. */ val rawArtists = - rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) } + rawIndividualArtists + .ifEmpty { rawAlbumArtists } + .distinctBy { it.key } + .ifEmpty { listOf(RawArtist()) } /** * The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a @@ -169,6 +173,7 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son rawSong.genreNames .parseId3GenreNames(musicSettings) .map { RawGenre(it) } + .distinctBy { it.key } .ifEmpty { listOf(RawGenre()) } /** @@ -207,6 +212,7 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son checkNotNull(_album) { "Malformed song: No album" } check(_artists.isNotEmpty()) { "Malformed song: No artists" } + check(_artists.size == rawArtists.size) { "Malformed song: Artist grouping mismatch" } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. @@ -217,6 +223,7 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son } check(_genres.isNotEmpty()) { "Malformed song: No genres" } + check(_genres.size == rawGenres.size) { "Malformed song: Genre grouping mismatch" } for (i in _genres.indices) { // Non-destructively reorder the linked genres so that they align with // the genre ordering within the song metadata. @@ -334,6 +341,7 @@ class AlbumImpl( fun finalize(): Album { check(songs.isNotEmpty()) { "Malformed album: Empty" } check(_artists.isNotEmpty()) { "Malformed album: No artists" } + check(_artists.size == rawArtists.size) { "Malformed album: Artist grouping mismatch" } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 42e29eed2..11a357f45 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -364,7 +364,7 @@ private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSet arrayOf( MediaStore.Audio.AudioColumns.TRACK, // Below API 29, we are restricted to the absolute path (Called DATA by - // MedaStore) when working with audio files. + // MediaStore) when working with audio files. MediaStore.Audio.AudioColumns.DATA) // The selector should be configured to convert the given directories instances to their diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 5d9eebcbc..fae02585e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -149,18 +149,22 @@ private class TagWorkerImpl( // Artist textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } - (textFrames["TXXX:artistssort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"]) + (textFrames["TXXX:artistssort"] + ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] + ?: textFrames["TSOP"]) ?.let { rawSong.artistSortNames = it } // Album artist textFrames["TXXX:musicbrainz album artist id"]?.let { rawSong.albumArtistMusicBrainzIds = it } - (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { - rawSong.albumArtistNames = it - } + (textFrames["TXXX:albumartists"] + ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] + ?: textFrames["TPE2"]) + ?.let { rawSong.albumArtistNames = it } (textFrames["TXXX:albumartistssort"] - ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartistsort"] + ?: textFrames["TXXX:albumartists_sort"] ?: textFrames["TXXX:albumartists sort"] + ?: textFrames["TXXX:albumartistsort"] // This is a non-standard iTunes extension ?: textFrames["TSO2"]) ?.let { rawSong.albumArtistSortNames = it } @@ -261,17 +265,19 @@ private class TagWorkerImpl( // Artist comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } - (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artistsort"])?.let { - rawSong.artistSortNames = it - } + (comments["artistssort"] + ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) + ?.let { rawSong.artistSortNames = it } // Album artist comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } - (comments["albumartists"] ?: comments["album_artists"] ?: comments["albumartist"])?.let { - rawSong.albumArtistNames = it - } + (comments["albumartists"] + ?: comments["album_artists"] ?: comments["album artists"] + ?: comments["albumartist"]) + ?.let { rawSong.albumArtistNames = it } (comments["albumartistssort"] - ?: comments["albumartists_sort"] ?: comments["albumartistsort"]) + ?: comments["albumartists_sort"] ?: comments["albumartists sort"] + ?: comments["albumartistsort"]) ?.let { rawSong.albumArtistSortNames = it } // Genre diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index bb1dda066..5e0e7ca55 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -39,6 +39,7 @@ import org.oxycblt.auxio.util.logE * @author Alexander Capehart * * TODO: Communicate errors + * TODO: How to handle empty playlists that appear because all of their songs have disappeared? */ interface UserLibrary { /** The current user-defined playlists. */ 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 26629030b..ffcc84b41 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 @@ -25,6 +25,7 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.media.audiofx.AudioEffect +import android.os.Build import android.os.IBinder import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -150,8 +151,8 @@ class PlaybackService : playbackManager.registerInternalPlayer(this) musicRepository.addUpdateListener(this) mediaSessionComponent.registerListener(this) - registerReceiver( - systemReceiver, + + val intentFilter = IntentFilter().apply { addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) addAction(AudioManager.ACTION_HEADSET_PLUG) @@ -162,7 +163,20 @@ class PlaybackService : addAction(ACTION_SKIP_NEXT) addAction(ACTION_EXIT) addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - }) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + registerReceiver( + systemReceiver, + intentFilter, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RECEIVER_NOT_EXPORTED + } else { + 0 + }) + } else { + registerReceiver(systemReceiver, intentFilter) + } logD("Service created") } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 947362fe7..8db338dd5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -94,11 +94,11 @@ interface Settings { final override fun onSharedPreferenceChanged( sharedPreferences: SharedPreferences, - key: String + key: String? ) { // FIXME: Settings initialization firing the listener. logD("Dispatching settings change $key") - onSettingChanged(key, unlikelyToBeNull(listener)) + onSettingChanged(unlikelyToBeNull(key), unlikelyToBeNull(listener)) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index b0b59d02b..9e1e83af5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -47,7 +47,8 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) } override fun onSetupPreference(preference: Preference) { - if (preference.key == getString(R.string.set_key_cover_mode)) { + if (preference.key == getString(R.string.set_key_cover_mode) || + preference.key == getString(R.string.set_key_square_covers)) { logD("Configuring cover mode setting") preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt index ff54bcfac..c959d0b3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt @@ -26,11 +26,11 @@ import androidx.preference.PreferenceGroupAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.R -import com.google.android.material.divider.BackportMaterialDividerItemDecoration +import com.google.android.material.divider.MaterialDividerItemDecoration /** - * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly - * separate preference categories. + * A [MaterialDividerItemDecoration] that sets up the divider configuration to correctly separate + * preference categories. * * @author Alexander Capehart (OxygenCobalt) */ @@ -41,7 +41,7 @@ constructor( attributeSet: AttributeSet? = null, defStyleAttr: Int = R.attr.materialDividerStyle, orientation: Int = LinearLayoutManager.VERTICAL -) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { +) : MaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { @SuppressLint("RestrictedApi") override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = try { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 1cb3f599c..451dcabd7 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -27,7 +27,8 @@ import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.image.RoundedCornersTransformation +import org.oxycblt.auxio.image.extractor.RoundedRectTransformation +import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.queue.Queue @@ -98,10 +99,19 @@ constructor( return if (cornerRadius > 0) { // If rounded, reduce the bitmap size further to obtain more pronounced // rounded corners. - builder - .size(getSafeRemoteViewsImageSize(context, 10f)) - .transformations(RoundedCornersTransformation(cornerRadius.toFloat())) + builder.size(getSafeRemoteViewsImageSize(context, 10f)) + val cornersTransformation = + RoundedRectTransformation(cornerRadius.toFloat()) + if (imageSettings.forceSquareCovers) { + builder.transformations( + SquareCropTransformation.INSTANCE, cornersTransformation) + } else { + builder.transformations(cornersTransformation) + } } else { + if (imageSettings.forceSquareCovers) { + builder.transformations(SquareCropTransformation.INSTANCE) + } builder.size(getSafeRemoteViewsImageSize(context)) } } diff --git a/app/src/main/res/drawable/ic_shuffle_off_24.xml b/app/src/main/res/drawable/ic_shuffle_off_24.xml index ec36a047c..d73a3c489 100644 --- a/app/src/main/res/drawable/ic_shuffle_off_24.xml +++ b/app/src/main/res/drawable/ic_shuffle_off_24.xml @@ -2,10 +2,10 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + diff --git a/app/src/main/res/drawable/ic_shuffle_on_24.xml b/app/src/main/res/drawable/ic_shuffle_on_24.xml index 8a64e052f..1e4330f55 100644 --- a/app/src/main/res/drawable/ic_shuffle_on_24.xml +++ b/app/src/main/res/drawable/ic_shuffle_on_24.xml @@ -1,10 +1,11 @@ + - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorPrimary"> + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 561b05892..ed22aa75f 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -293,4 +293,6 @@ Няма дыска З\'яўляецца на Рэдагаванне %s + Выкарыстоўваць квадратныя вокладкі альбомаў + Абрэзаць усе вокладкі альбомаў да суадносін бакоў 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 956f74699..6abd46909 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -55,9 +55,9 @@ Nastavení Vzhled a chování Motiv - Automatické - Světlé - Tmavé + Automaticky + Světlý + Tmavý Barevné schéma Černý motiv Použít kompletně černý tmavý motiv @@ -304,4 +304,6 @@ Žádný disk Úprava seznamu %s Sdílet + Vynutit čtvercové obaly alb + Oříznout všechny covery alb na poměr stran 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03ca85350..2f944921a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -295,4 +295,6 @@ Keine Disc Erscheint in %s bearbeiten + Quadratische Album-Cover erzwingen + Alle Album-Cover auf ein Seitenverhältnis von 1:1 zuschneiden \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3a7e94380..9fd873733 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -299,4 +299,6 @@ Aparece en Compartir Sin disco + Carátula del álbum Force Square + Recorta todas las portadas de los álbumes a una relación de aspecto 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 55adf537f..ce20da39c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -297,4 +297,6 @@ Codage audio avancé (AAC) Aucun disque %1$s, %2$s + Forcer les pochettes d\'album carrées + Recadrer toutes les pochettes d\'album au format 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 7382cab1d..4809c780e 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -290,4 +290,6 @@ Sudjelovanja: Dijeli Nema diska + Prisili kvadratične omote albuma + Odreži sve omote albuma na omjer 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index f51c29ee1..592ef9dc7 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -180,7 +180,7 @@ ਕੌਮਾ (,) ਸੈਮੀਕੋਲਨ (;) ਸਲੈਸ਼ (/) - Ampersand (&) + ਐਂਪਰਸੈਂਡ (&) ਸਹਿਯੋਗੀਆਂ ਨੂੰ ਲੁਕਾਓ ਆਵਾਜ਼ ਅਤੇ ਪਲੇਬੈਕ ਵਿਵਹਾਰ ਦੀ ਸੰਰਚਨਾ ਕਰੋ ਪਲੇਅਬੈਕ @@ -219,7 +219,7 @@ Matroska ਆਡੀਓ ਗੂੜ੍ਹਾ ਜ੍ਹਾਮਣੀ Ogg ਆਡੀਓ - % d: ਗੀਤ ਲੋਡ ਕੀਤੇ + %d: ਗੀਤ ਲੋਡ ਕੀਤੇ %d ਐਲਬਮ %d ਐਲਬਮਾਂ @@ -287,4 +287,6 @@ ਡਾਇਨੈਮਿਕ %s ਸੋਧ ਰਿਹਾ ਤੁਹਾਡੀ ਸੰਗੀਤ ਲਾਇਬਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… (%1$d/%2$d) + ਵਰਗੀਕ੍ਰਿਤ ਐਲਬਮ ਕਵਰ ਫੋਰਸ ਕਰੋ + ਸਾਰੇ ਐਲਬਮ ਕਵਰਾਂ ਨੂੰ 1:1 ਦੇ ਆਕਾਰ ਅਨੁਪਾਤ ਤੱਕ ਕਾਂਟ-ਛਾਂਟ ਕਰੋ \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8016b72f2..d433b61c4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -40,7 +40,7 @@ Nie znaleziono utworów Utwór %d - Odtwórz bądź zapauzuj + Odtwórz albo zapauzuj Szukaj w bibliotece… @@ -102,7 +102,7 @@ Minialbum koncertowy Minialbum z remiksami Koncertowy singiel - Remiks + Remix Kompilacje Kompilacja Ścieżki dźwiękowe @@ -139,7 +139,7 @@ Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne Usuń utwór z kolejki Preferuj album - Automatycznie odśwież + Automatyczne odświeżanie FLAC Et (&) Nie udało się zaimportować utworów @@ -243,7 +243,7 @@ Wyłączone Niska jakość Wysoka jakość - Uwaga: To ustawienie może powodować nieprawidłowe przetwarzenie tagów - tak, jakby posiadały wiele wartości. Problem ten należy rozwiązać stawiając ukośnik wsteczny (\\) przed niepożądanymi znakami traktowanymi jako oddzielające. + Uwaga: To ustawienie może powodować nieprawidłowe przetwarzenie tagów (tak, jakby posiadały wiele wartości). Problem ten należy rozwiązać stawiając ukośnik wsteczny (\\) przed niepożądanymi znakami oddzielającymi. Dostosuj motyw i kolory aplikacji Ukryj wykonawców uczestniczących Zarządzaj importowaniem muzyki i obrazów @@ -254,7 +254,7 @@ Foldery Stan odtwarzania Obrazy - Zarządzanie dźwiękiem i odtwarzaniem muzyki + Zarządzaj dźwiękiem i odtwarzaniem muzyki Odtwórz wybrane Wybrane losowo Wybrano %d @@ -288,16 +288,18 @@ Brak utworów Dodano do playlisty Playlista %d - Usuwać + Usuń Usunąć %s\? Tego nie da się cofnąć. - Przemianować - Przemianować playlistę + Zmień nazwę + Zmień nazwę playlisty Usunąć playlistę\? - Edytować - Pojawia się - Udział + Edytuj + Pojawia się na + Udostępnij Zmieniono nazwę playlisty - Playlista usunięta - Brak dysku + Usunięto playlistę + Brak nr. płyty Edytowanie %s + Przytnij okładki do formatu 1:1 + Wymuś kwadratowe okładki \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6091d3ea4..43daae2e2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -302,4 +302,6 @@ Редактирование %s Нет диска Поделиться + Использовать квадратные обложки альбомов + Обрезать все обложки альбомов до соотношения сторон 1:1 \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml new file mode 100644 index 000000000..7acaab1b1 --- /dev/null +++ b/app/src/main/res/values-sv/strings.xml @@ -0,0 +1,156 @@ + + + Försök igen + Musik laddar + Laddar musik + Alla låtar + Album + Albumet + Remix-album + EP + EP + Live-EP + Remix-EP + Singlar + Remix-singel + Sammanställning + Remix-sammanställning + Ljudspår + Ljudspår + Blandband + DJ-mixar + Live + Remixar + Framträder på + Konstnär + Konstnär + Genrer + Spellista + Spellistor + Ny spellista + Byt namn på spellista + Ta bort spellista\? + Sök + Filtrera + Namn + Datum + Längd + Antal låtar + Spår + Datum tillagt + Stigande + Fallande + Nu spelar + Utjämnare + Spela + Spela utvalda + Blanda + + Spela nästa + Lägg till spellista + Gå till konstnär + Gå till album + Visa egenskaper + Dela + Egenskaper för låt + Överordnad mapp + Format + Storlek + Samplingsfrekvens + Blanda + Blanda alla + Okej + Avbryt + Spara + Tillstånd återstallde + Om + Källkod + Wiki + Licenser + Visa och kontrollera musikuppspelning + Laddar ditt musikbibliotek… + Övervakning ditt musikbibliotek för ändringar… + Tillagd till kö + Spellista skapade + Tillagd till spellista + Sök ditt musikbibliotek… + Inställningar + Utseende + Ändra tema och färger på appen + Automatisk + Ljust + Svart tema + Rundläge + Bevilja + En enkel, rationell musikspelare för Android. + Övervakar musikbiblioteket + Låtar + Live-album + Ta bort + Live-sammanställning + Singel + Live-singel + Sammanställningar + Blandband + DJ-mix + Genre + Byt namn + Redigera + Alla + Disk + Sortera + Blanda utvalda + Lägg till kö + Filnamn + Lägg till + Tillstånd tog bort + Bithastighet + Återställ + Tillstånd sparat + Version + Statistik över beroende + Bytt namn av spellista + Spellista tog bort + Utvecklad av Alexander Capeheart + Tema + Mörkt + Färgschema + Använda rent svart för det mörka temat + Aktivera rundade hörn på ytterligare element i användargränssnittet (kräver att albumomslag är rundade) + Anpassa + Ändra synlighet och ordningsföljd av bibliotekflikar + Anpassad åtgärd för uppspelningsfält + Anpassad aviseringsåtgärd + Hoppa till nästa + Upprepningsmodus + Beteende + När spelar från artikeluppgifter + Spela från genre + Komma ihåg blandningsstatus + Behåll blandning på när spelar en ny låt + Kontent + Kontrollera hur musik och bilar laddas + Musik + Automatisk omladdning + Inkludera bara musik + Ignorera ljudfiler som inte är musik, t.ex. podkaster + Värdeavskiljare + Plus (+) + Intelligent sortering + Sorterar namn som börjar med siffror eller ord som \"the\" korrekt (fungerar bäst med engelskspråkig music) + Dölj medarbetare + Skärm + Bibliotekflikar + När spelar från biblioteket + Spela från visad artikel + Spela från alla låtar + Spela från konstnär + Spela från album + Semikolon (;) + Ladda om musikbiblioteket när det ändras (kräver permanent meddelande) + Komma (,) + Snedstreck (/) + Konfigurera tecken som separerar flera värden i taggar + Advarsel: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\). + Anpassa UI-kontroller och beteende + \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a9a5c1e6a..8f7435242 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -299,4 +299,6 @@ Редагування %s Немає диску З\'являється на + Обрізання обкладинки альбомів до співвідношення сторін 1:1 + Примусові квадратні обкладинки \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 52243b67a..4a5290943 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -293,4 +293,6 @@ 分享 无唱片 正在编辑 %s + 强制使用方形专辑封面 + 将所有专辑封面裁剪至 1:1 宽高比 \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 7dffd1917..5295acb05 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,7 +20,6 @@ 16dp 24dp - 48dp 56dp 64dp diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index faab5d5cd..f3c956766 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -15,6 +15,7 @@ auxio_observing auxio_music_dirs auxio_cover_mode + auxio_square_covers auxio_include_dirs auxio_exclude_non_music auxio_separators diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 676133e13..95e6c348e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,6 +239,8 @@ Off Fast High quality + Force square album covers + Crop all album covers to a 1:1 aspect ratio Audio Configure sound and playback behavior diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 97611ce8d..5ee16b401 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -216,7 +216,7 @@ @dimen/spacing_small @dimen/spacing_small @dimen/spacing_small - + @color/m3_text_button_foreground_color_selector diff --git a/app/src/main/res/xml/preferences_music.xml b/app/src/main/res/xml/preferences_music.xml index a46bb1025..86a3a6b03 100644 --- a/app/src/main/res/xml/preferences_music.xml +++ b/app/src/main/res/xml/preferences_music.xml @@ -43,5 +43,11 @@ app:key="@string/set_key_cover_mode" app:title="@string/set_cover_mode" /> + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index dbb61d537..5c4648215 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { - kotlin_version = '1.8.21' - navigation_version = "2.5.3" + kotlin_version = '1.8.22' + navigation_version = "2.6.0" hilt_version = '2.46.1' } diff --git a/fastlane/metadata/android/en-US/changelogs/33.txt b/fastlane/metadata/android/en-US/changelogs/33.txt new file mode 100644 index 000000000..5915176d0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33.txt @@ -0,0 +1,4 @@ +Auxio 3.1.0 introduces playlisting functionality, with more features coming soon. +This release fixes an issue where some users would experience an infinite loading +screen, along other quality-of-life improvements. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.1.3. \ No newline at end of file diff --git a/fastlane/metadata/android/sv/short_description.txt b/fastlane/metadata/android/sv/short_description.txt new file mode 100644 index 000000000..107ba2121 --- /dev/null +++ b/fastlane/metadata/android/sv/short_description.txt @@ -0,0 +1 @@ +En enkel, rationell musikspelare