Merge pull request #491 from OxygenCobalt/dev

Version 3.1.3
This commit is contained in:
Alexander Capehart 2023-06-26 18:48:32 -06:00 committed by GitHub
commit e7d391b050
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 885 additions and 702 deletions

View file

@ -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

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.2">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.2&color=64B5F6&style=flat">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.1.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.3&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">

View file

@ -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"

View file

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->

View file

@ -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;
* <p>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<V extends View> extends CoordinatorLayout.Behavior<V> {
public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V>
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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> extends CoordinatorLayo
int parentHeight;
@Nullable WeakReference<V> viewRef;
@Nullable WeakReference<View> accessibilityDelegateViewRef;
@Nullable WeakReference<View> nestedScrollingChildRef;
@NonNull private final ArrayList<BottomSheetCallback> 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<View, Integer> importantForAccessibilityMap;
private int expandHalfwayActionId = View.NO_ID;
@VisibleForTesting
final SparseIntArray expandHalfwayActionIds = new SparseIntArray();
public BackportBottomSheetBehavior() {}
@ -387,6 +406,11 @@ public class BackportBottomSheetBehavior<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> extends CoordinatorLayo
return gestureInsetBottomIgnored;
}
/**
* Sets whether the bottom sheet should remove its corners when it reaches the expanded state.
*
* <p>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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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<V>) behavior;
}
@ -2139,30 +2331,43 @@ public class BackportBottomSheetBehavior<V extends View> 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<V extends View> 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<V extends View> extends CoordinatorLayo
};
}
}

View file

@ -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.
*
* <pre>
* dividerItemDecoration = new MaterialDividerItemDecoration(recyclerView.getContext(),
* layoutManager.getOrientation());
* recyclerView.addItemDecoration(dividerItemDecoration);
* </pre>
*
* 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.
*
* <p>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.
*
* <p>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;
}
}

View file

@ -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
}

View file

@ -34,6 +34,8 @@ import org.oxycblt.auxio.util.logD
interface ImageSettings : Settings<ImageSettings.Listener> {
/** 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) ||

View file

@ -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<InputStream>, size: Size): FetchResult {
private suspend fun createMosaic(streams: List<InputStream>, 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
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 &&

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -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<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val processedSongs = Channel<RawSong>(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<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
val processedSongs = Channel<RawSong>(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<RawSong>()
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 <R> 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) {

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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. */

View file

@ -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")
}

View file

@ -94,11 +94,11 @@ interface Settings<L> {
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))
}
/**

View file

@ -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 { _, _ ->

View file

@ -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 {

View file

@ -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))
}
}

View file

@ -2,10 +2,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9.175,10.575 L4,5.4 5.4,4 10.575,9.175ZM14,20V18H16.6L13.425,14.825L14.85,13.4L18,16.55V14H20V20ZM5.4,20 L4,18.6 16.6,6H14V4H20V10H18V7.4Z" />
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M560,800L560,720L664,720L537,593L594,536L720,662L720,560L800,560L800,800L560,800ZM216,800L160,744L664,240L560,240L560,160L800,160L800,400L720,400L720,296L216,800ZM367,423L160,216L216,160L423,367L367,423Z"/>
</vector>

View file

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9.05,10.775 L3.675,5.4 5.45,3.625 10.825,9ZM13.75,20.275V17.725H16L13.275,14.975L15.025,13.2L17.75,15.925V13.725H20.325V20.275ZM5.45,20.375 L3.675,18.6 16,6.275H13.75V3.725H20.325V10.275H17.75V8.05Z" />
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M546,817L546,703L627,703L528,603L607,524L706,623L706,543L820,543L820,817L546,817ZM219,823L140,744L627,257L546,257L546,143L820,143L820,417L706,417L706,336L219,823ZM359,435L140,216L219,137L438,356L359,435Z"/>
</vector>

View file

@ -293,4 +293,6 @@
<string name="def_disc">Няма дыска</string>
<string name="lbl_appears_on">З\'яўляецца на</string>
<string name="fmt_editing">Рэдагаванне %s</string>
<string name="set_square_covers">Выкарыстоўваць квадратныя вокладкі альбомаў</string>
<string name="set_square_covers_desc">Абрэзаць усе вокладкі альбомаў да суадносін бакоў 1:1</string>
</resources>

View file

@ -55,9 +55,9 @@
<string name="set_root_title">Nastavení</string>
<string name="set_ui">Vzhled a chování</string>
<string name="set_theme">Motiv</string>
<string name="set_theme_auto">Automatické</string>
<string name="set_theme_day">Světlé</string>
<string name="set_theme_night">Tmavé</string>
<string name="set_theme_auto">Automaticky</string>
<string name="set_theme_day">Světlý</string>
<string name="set_theme_night">Tmavý</string>
<string name="set_accent">Barevné schéma</string>
<string name="set_black_mode">Černý motiv</string>
<string name="set_black_mode_desc">Použít kompletně černý tmavý motiv</string>
@ -304,4 +304,6 @@
<string name="def_disc">Žádný disk</string>
<string name="fmt_editing">Úprava seznamu %s</string>
<string name="lbl_share">Sdílet</string>
<string name="set_square_covers">Vynutit čtvercové obaly alb</string>
<string name="set_square_covers_desc">Oříznout všechny covery alb na poměr stran 1:1</string>
</resources>

View file

@ -295,4 +295,6 @@
<string name="def_disc">Keine Disc</string>
<string name="lbl_appears_on">Erscheint in</string>
<string name="fmt_editing">%s bearbeiten</string>
<string name="set_square_covers">Quadratische Album-Cover erzwingen</string>
<string name="set_square_covers_desc">Alle Album-Cover auf ein Seitenverhältnis von 1:1 zuschneiden</string>
</resources>

View file

@ -299,4 +299,6 @@
<string name="lbl_appears_on">Aparece en</string>
<string name="lbl_share">Compartir</string>
<string name="def_disc">Sin disco</string>
<string name="set_square_covers">Carátula del álbum Force Square</string>
<string name="set_square_covers_desc">Recorta todas las portadas de los álbumes a una relación de aspecto 1:1</string>
</resources>

View file

@ -297,4 +297,6 @@
<string name="cdc_aac">Codage audio avancé (AAC)</string>
<string name="def_disc">Aucun disque</string>
<string name="fmt_list">%1$s, %2$s</string>
<string name="set_square_covers">Forcer les pochettes d\'album carrées</string>
<string name="set_square_covers_desc">Recadrer toutes les pochettes d\'album au format 1:1</string>
</resources>

View file

@ -290,4 +290,6 @@
<string name="lbl_appears_on">Sudjelovanja:</string>
<string name="lbl_share">Dijeli</string>
<string name="def_disc">Nema diska</string>
<string name="set_square_covers">Prisili kvadratične omote albuma</string>
<string name="set_square_covers_desc">Odreži sve omote albuma na omjer 1:1</string>
</resources>

View file

@ -180,7 +180,7 @@
<string name="set_separators_comma">ਕੌਮਾ (,)</string>
<string name="set_separators_semicolon">ਸੈਮੀਕੋਲਨ (;)</string>
<string name="set_separators_slash">ਸਲੈਸ਼ (/)</string>
<string name="set_separators_and">Ampersand (&amp;)</string>
<string name="set_separators_and">ਐਂਪਰਸੈਂਡ (&amp;)</string>
<string name="set_hide_collaborators">ਸਹਿਯੋਗੀਆਂ ਨੂੰ ਲੁਕਾਓ</string>
<string name="set_audio_desc">ਆਵਾਜ਼ ਅਤੇ ਪਲੇਬੈਕ ਵਿਵਹਾਰ ਦੀ ਸੰਰਚਨਾ ਕਰੋ</string>
<string name="set_playback">ਪਲੇਅਬੈਕ</string>
@ -219,7 +219,7 @@
<string name="cdc_mka">Matroska ਆਡੀਓ</string>
<string name="clr_deep_purple">ਗੂੜ੍ਹਾ ਜ੍ਹਾਮਣੀ</string>
<string name="cdc_ogg">Ogg ਆਡੀਓ</string>
<string name="fmt_lib_song_count">% d: ਗੀਤ ਲੋਡ ਕੀਤੇ</string>
<string name="fmt_lib_song_count">%d: ਗੀਤ ਲੋਡ ਕੀਤੇ</string>
<plurals name="fmt_album_count">
<item quantity="one">%d ਐਲਬਮ</item>
<item quantity="other">%d ਐਲਬਮਾਂ</item>
@ -287,4 +287,6 @@
<string name="clr_dynamic">ਡਾਇਨੈਮਿਕ</string>
<string name="fmt_editing">%s ਸੋਧ ਰਿਹਾ</string>
<string name="fmt_indexing">ਤੁਹਾਡੀ ਸੰਗੀਤ ਲਾਇਬਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… (%1$d/%2$d)</string>
<string name="set_square_covers">ਵਰਗੀਕ੍ਰਿਤ ਐਲਬਮ ਕਵਰ ਫੋਰਸ ਕਰੋ</string>
<string name="set_square_covers_desc">ਸਾਰੇ ਐਲਬਮ ਕਵਰਾਂ ਨੂੰ 1:1 ਦੇ ਆਕਾਰ ਅਨੁਪਾਤ ਤੱਕ ਕਾਂਟ-ਛਾਂਟ ਕਰੋ</string>
</resources>

View file

@ -40,7 +40,7 @@
<string name="err_no_music">Nie znaleziono utworów</string>
<!-- Description Namespace | Accessibility Strings -->
<string name="desc_track_number">Utwór %d</string>
<string name="desc_play_pause">Odtwórz bądź zapauzuj</string>
<string name="desc_play_pause">Odtwórz albo zapauzuj</string>
<!-- Hint Namespace | EditText Hints -->
<string name="lng_search_library">Szukaj w bibliotece…</string>
<!-- Color Label namespace | Accent names -->
@ -102,7 +102,7 @@
<string name="lbl_ep_live">Minialbum koncertowy</string>
<string name="lbl_ep_remix">Minialbum z remiksami</string>
<string name="lbl_single_live">Koncertowy singiel</string>
<string name="lbl_single_remix">Remiks</string>
<string name="lbl_single_remix">Remix</string>
<string name="lbl_compilations">Kompilacje</string>
<string name="lbl_compilation">Kompilacja</string>
<string name="lbl_soundtracks">Ścieżki dźwiękowe</string>
@ -139,7 +139,7 @@
<string name="set_reindex_desc">Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne</string>
<string name="desc_remove_song">Usuń utwór z kolejki</string>
<string name="set_replay_gain_mode_album">Preferuj album</string>
<string name="set_observing">Automatycznie odśwież</string>
<string name="set_observing">Automatyczne odświeżanie</string>
<string name="cdc_flac">FLAC</string>
<string name="set_separators_and">Et (&amp;)</string>
<string name="err_index_failed">Nie udało się zaimportować utworów</string>
@ -243,7 +243,7 @@
<string name="set_cover_mode_off">Wyłączone</string>
<string name="set_cover_mode_media_store">Niska jakość</string>
<string name="set_cover_mode_quality">Wysoka jakość</string>
<string name="set_separators_warning">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.</string>
<string name="set_separators_warning">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.</string>
<string name="set_ui_desc">Dostosuj motyw i kolory aplikacji</string>
<string name="set_hide_collaborators">Ukryj wykonawców uczestniczących</string>
<string name="set_content_desc">Zarządzaj importowaniem muzyki i obrazów</string>
@ -254,7 +254,7 @@
<string name="set_dirs_list">Foldery</string>
<string name="set_state">Stan odtwarzania</string>
<string name="set_images">Obrazy</string>
<string name="set_audio_desc">Zarządzanie dźwiękiem i odtwarzaniem muzyki</string>
<string name="set_audio_desc">Zarządzaj dźwiękiem i odtwarzaniem muzyki</string>
<string name="lbl_play_selected">Odtwórz wybrane</string>
<string name="lbl_shuffle_selected">Wybrane losowo</string>
<string name="fmt_selected">Wybrano %d</string>
@ -288,16 +288,18 @@
<string name="def_song_count">Brak utworów</string>
<string name="lng_playlist_added">Dodano do playlisty</string>
<string name="fmt_def_playlist">Playlista %d</string>
<string name="lbl_delete">Usuwać</string>
<string name="lbl_delete">Usuń</string>
<string name="fmt_deletion_info">Usunąć %s\? Tego nie da się cofnąć.</string>
<string name="lbl_rename">Przemianować</string>
<string name="lbl_rename_playlist">Przemianować playlistę</string>
<string name="lbl_rename">Zmień nazwę</string>
<string name="lbl_rename_playlist">Zmień nazwę playlisty</string>
<string name="lbl_confirm_delete_playlist">Usunąć playlistę\?</string>
<string name="lbl_edit">Edytować</string>
<string name="lbl_appears_on">Pojawia się</string>
<string name="lbl_share">Udział</string>
<string name="lbl_edit">Edytuj</string>
<string name="lbl_appears_on">Pojawia się na</string>
<string name="lbl_share">Udostępnij</string>
<string name="lng_playlist_renamed">Zmieniono nazwę playlisty</string>
<string name="lng_playlist_deleted">Playlista usunięta</string>
<string name="def_disc">Brak dysku</string>
<string name="lng_playlist_deleted">Usunięto playlistę</string>
<string name="def_disc">Brak nr. płyty</string>
<string name="fmt_editing">Edytowanie %s</string>
<string name="set_square_covers_desc">Przytnij okładki do formatu 1:1</string>
<string name="set_square_covers">Wymuś kwadratowe okładki</string>
</resources>

View file

@ -302,4 +302,6 @@
<string name="fmt_editing">Редактирование %s</string>
<string name="def_disc">Нет диска</string>
<string name="lbl_share">Поделиться</string>
<string name="set_square_covers">Использовать квадратные обложки альбомов</string>
<string name="set_square_covers_desc">Обрезать все обложки альбомов до соотношения сторон 1:1</string>
</resources>

View file

@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="lbl_retry">Försök igen</string>
<string name="lbl_indexer">Musik laddar</string>
<string name="lbl_indexing">Laddar musik</string>
<string name="lbl_all_songs">Alla låtar</string>
<string name="lbl_albums">Album</string>
<string name="lbl_album">Albumet</string>
<string name="lbl_album_remix">Remix-album</string>
<string name="lbl_eps">EP</string>
<string name="lbl_ep">EP</string>
<string name="lbl_ep_live">Live-EP</string>
<string name="lbl_ep_remix">Remix-EP</string>
<string name="lbl_singles">Singlar</string>
<string name="lbl_single_remix">Remix-singel</string>
<string name="lbl_compilation">Sammanställning</string>
<string name="lbl_compilation_remix">Remix-sammanställning</string>
<string name="lbl_soundtracks">Ljudspår</string>
<string name="lbl_soundtrack">Ljudspår</string>
<string name="lbl_mixtapes">Blandband</string>
<string name="lbl_mixes">DJ-mixar</string>
<string name="lbl_live_group">Live</string>
<string name="lbl_remix_group">Remixar</string>
<string name="lbl_appears_on">Framträder på</string>
<string name="lbl_artist">Konstnär</string>
<string name="lbl_artists">Konstnär</string>
<string name="lbl_genres">Genrer</string>
<string name="lbl_playlist">Spellista</string>
<string name="lbl_playlists">Spellistor</string>
<string name="lbl_new_playlist">Ny spellista</string>
<string name="lbl_rename_playlist">Byt namn på spellista</string>
<string name="lbl_confirm_delete_playlist">Ta bort spellista\?</string>
<string name="lbl_search">Sök</string>
<string name="lbl_filter">Filtrera</string>
<string name="lbl_name">Namn</string>
<string name="lbl_date">Datum</string>
<string name="lbl_duration">Längd</string>
<string name="lbl_song_count">Antal låtar</string>
<string name="lbl_track">Spår</string>
<string name="lbl_date_added">Datum tillagt</string>
<string name="lbl_sort_asc">Stigande</string>
<string name="lbl_sort_dec">Fallande</string>
<string name="lbl_playback">Nu spelar</string>
<string name="lbl_equalizer">Utjämnare</string>
<string name="lbl_play">Spela</string>
<string name="lbl_play_selected">Spela utvalda</string>
<string name="lbl_shuffle">Blanda</string>
<string name="lbl_queue"></string>
<string name="lbl_play_next">Spela nästa</string>
<string name="lbl_playlist_add">Lägg till spellista</string>
<string name="lbl_go_artist">Gå till konstnär</string>
<string name="lbl_go_album">Gå till album</string>
<string name="lbl_song_detail">Visa egenskaper</string>
<string name="lbl_share">Dela</string>
<string name="lbl_props">Egenskaper för låt</string>
<string name="lbl_relative_path">Överordnad mapp</string>
<string name="lbl_format">Format</string>
<string name="lbl_size">Storlek</string>
<string name="lbl_sample_rate">Samplingsfrekvens</string>
<string name="lbl_shuffle_shortcut_short">Blanda</string>
<string name="lbl_shuffle_shortcut_long">Blanda alla</string>
<string name="lbl_ok">Okej</string>
<string name="lbl_cancel">Avbryt</string>
<string name="lbl_save">Spara</string>
<string name="lbl_state_restored">Tillstånd återstallde</string>
<string name="lbl_about">Om</string>
<string name="lbl_code">Källkod</string>
<string name="lbl_wiki">Wiki</string>
<string name="lbl_licenses">Licenser</string>
<string name="lng_widget">Visa och kontrollera musikuppspelning</string>
<string name="lng_indexing">Laddar ditt musikbibliotek…</string>
<string name="lng_observing">Övervakning ditt musikbibliotek för ändringar…</string>
<string name="lng_queue_added">Tillagd till kö</string>
<string name="lng_playlist_created">Spellista skapade</string>
<string name="lng_playlist_added">Tillagd till spellista</string>
<string name="lng_search_library">Sök ditt musikbibliotek…</string>
<string name="set_root_title">Inställningar</string>
<string name="set_ui">Utseende</string>
<string name="set_ui_desc">Ändra tema och färger på appen</string>
<string name="set_theme_auto">Automatisk</string>
<string name="set_theme_day">Ljust</string>
<string name="set_black_mode">Svart tema</string>
<string name="set_round_mode">Rundläge</string>
<string name="lbl_grant">Bevilja</string>
<string name="info_app_desc">En enkel, rationell musikspelare för Android.</string>
<string name="lbl_observing">Övervakar musikbiblioteket</string>
<string name="lbl_songs">Låtar</string>
<string name="lbl_album_live">Live-album</string>
<string name="lbl_delete">Ta bort</string>
<string name="lbl_compilation_live">Live-sammanställning</string>
<string name="lbl_single">Singel</string>
<string name="lbl_single_live">Live-singel</string>
<string name="lbl_compilations">Sammanställningar</string>
<string name="lbl_mixtape">Blandband</string>
<string name="lbl_mix">DJ-mix</string>
<string name="lbl_genre">Genre</string>
<string name="lbl_rename">Byt namn</string>
<string name="lbl_edit">Redigera</string>
<string name="lbl_filter_all">Alla</string>
<string name="lbl_disc">Disk</string>
<string name="lbl_sort">Sortera</string>
<string name="lbl_shuffle_selected">Blanda utvalda</string>
<string name="lbl_queue_add">Lägg till kö</string>
<string name="lbl_file_name">Filnamn</string>
<string name="lbl_add">Lägg till</string>
<string name="lbl_state_wiped">Tillstånd tog bort</string>
<string name="lbl_bitrate">Bithastighet</string>
<string name="lbl_reset">Återställ</string>
<string name="lbl_state_saved">Tillstånd sparat</string>
<string name="lbl_version">Version</string>
<string name="lbl_library_counts">Statistik över beroende</string>
<string name="lng_playlist_renamed">Bytt namn av spellista</string>
<string name="lng_playlist_deleted">Spellista tog bort</string>
<string name="lng_author">Utvecklad av Alexander Capeheart</string>
<string name="set_theme">Tema</string>
<string name="set_theme_night">Mörkt</string>
<string name="set_accent">Färgschema</string>
<string name="set_black_mode_desc">Använda rent svart för det mörka temat</string>
<string name="set_round_mode_desc">Aktivera rundade hörn på ytterligare element i användargränssnittet (kräver att albumomslag är rundade)</string>
<string name="set_personalize">Anpassa</string>
<string name="set_lib_tabs_desc">Ändra synlighet och ordningsföljd av bibliotekflikar</string>
<string name="set_bar_action">Anpassad åtgärd för uppspelningsfält</string>
<string name="set_notif_action">Anpassad aviseringsåtgärd</string>
<string name="set_action_mode_next">Hoppa till nästa</string>
<string name="set_action_mode_repeat">Upprepningsmodus</string>
<string name="set_behavior">Beteende</string>
<string name="set_detail_song_playback_mode">När spelar från artikeluppgifter</string>
<string name="set_playback_mode_genre">Spela från genre</string>
<string name="set_keep_shuffle">Komma ihåg blandningsstatus</string>
<string name="set_keep_shuffle_desc">Behåll blandning på när spelar en ny låt</string>
<string name="set_content">Kontent</string>
<string name="set_content_desc">Kontrollera hur musik och bilar laddas</string>
<string name="set_music">Musik</string>
<string name="set_observing">Automatisk omladdning</string>
<string name="set_exclude_non_music">Inkludera bara musik</string>
<string name="set_exclude_non_music_desc">Ignorera ljudfiler som inte är musik, t.ex. podkaster</string>
<string name="set_separators">Värdeavskiljare</string>
<string name="set_separators_plus">Plus (+)</string>
<string name="set_intelligent_sorting">Intelligent sortering</string>
<string name="set_intelligent_sorting_desc">Sorterar namn som börjar med siffror eller ord som \"the\" korrekt (fungerar bäst med engelskspråkig music)</string>
<string name="set_hide_collaborators">Dölj medarbetare</string>
<string name="set_display">Skärm</string>
<string name="set_lib_tabs">Bibliotekflikar</string>
<string name="set_library_song_playback_mode">När spelar från biblioteket</string>
<string name="set_playback_mode_none">Spela från visad artikel</string>
<string name="set_playback_mode_songs">Spela från alla låtar</string>
<string name="set_playback_mode_artist">Spela från konstnär</string>
<string name="set_playback_mode_album">Spela från album</string>
<string name="set_separators_semicolon">Semikolon (;)</string>
<string name="set_observing_desc">Ladda om musikbiblioteket när det ändras (kräver permanent meddelande)</string>
<string name="set_separators_comma">Komma (,)</string>
<string name="set_separators_slash">Snedstreck (/)</string>
<string name="set_separators_desc">Konfigurera tecken som separerar flera värden i taggar</string>
<string name="set_separators_warning">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 (\\).</string>
<string name="set_personalize_desc">Anpassa UI-kontroller och beteende</string>
</resources>

View file

@ -299,4 +299,6 @@
<string name="fmt_editing">Редагування %s</string>
<string name="def_disc">Немає диску</string>
<string name="lbl_appears_on">З\'являється на</string>
<string name="set_square_covers_desc">Обрізання обкладинки альбомів до співвідношення сторін 1:1</string>
<string name="set_square_covers">Примусові квадратні обкладинки</string>
</resources>

View file

@ -293,4 +293,6 @@
<string name="lbl_share">分享</string>
<string name="def_disc">无唱片</string>
<string name="fmt_editing">正在编辑 %s</string>
<string name="set_square_covers">强制使用方形专辑封面</string>
<string name="set_square_covers_desc">将所有专辑封面裁剪至 1:1 宽高比</string>
</resources>

View file

@ -20,7 +20,6 @@
<dimen name="size_corners_medium">16dp</dimen>
<dimen name="size_corners_mid_large">24dp</dimen>
<dimen name="size_btn">48dp</dimen>
<dimen name="size_accent_item">56dp</dimen>
<dimen name="size_bottom_sheet_bar">64dp</dimen>

View file

@ -15,6 +15,7 @@
<string name="set_key_observing" translatable="false">auxio_observing</string>
<string name="set_key_music_dirs" translatable="false">auxio_music_dirs</string>
<string name="set_key_cover_mode" translatable="false">auxio_cover_mode</string>
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
<string name="set_key_separators" translatable="false">auxio_separators</string>

View file

@ -239,6 +239,8 @@
<string name="set_cover_mode_off">Off</string>
<string name="set_cover_mode_media_store">Fast</string>
<string name="set_cover_mode_quality">High quality</string>
<string name="set_square_covers">Force square album covers</string>
<string name="set_square_covers_desc">Crop all album covers to a 1:1 aspect ratio</string>
<string name="set_audio">Audio</string>
<string name="set_audio_desc">Configure sound and playback behavior</string>

View file

@ -216,7 +216,7 @@
<item name="android:paddingEnd">@dimen/spacing_small</item>
<item name="android:paddingTop">@dimen/spacing_small</item>
<item name="android:paddingBottom">@dimen/spacing_small</item>
<!-- Intentional to prevent button from spamming the log console over -->
<!-- Intentional to prevent button from spamming the log console -->
<item name="iconTint">@color/m3_text_button_foreground_color_selector</item>
</style>

View file

@ -43,5 +43,11 @@
app:key="@string/set_key_cover_mode"
app:title="@string/set_cover_mode" />
<SwitchPreferenceCompat
app:defaultValue="false"
app:key="@string/set_key_square_covers"
app:summary="@string/set_square_covers_desc"
app:title="@string/set_square_covers" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -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'
}

View file

@ -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.

View file

@ -0,0 +1 @@
En enkel, rationell musikspelare