extends CoordinatorLayo
gestureInsetBottom = mandatoryGestureInsets.bottom;
}
- // Don't update the peek height to be above the navigation bar or gestures if these
- // flags are off. It means the client is already handling it.
- if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) {
- updatePeekHeight(/* animate= */ false);
- }
+ // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
+ // don't need peek height adjustments (Despite the fact that they still likely padding
+ // the view, just without clipping anything)
+ updatePeekHeight(/* animate= */ false);
return insets;
}
});
diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java
new file mode 100644
index 000000000..060fe04d2
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java
@@ -0,0 +1,549 @@
+/*
+ * Copyright (C) 2015 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.bottomsheet;
+
+import com.google.android.material.R;
+
+import static com.google.android.material.color.MaterialColors.isColorLight;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.os.Build;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatDialog;
+import android.util.TypedValue;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager.LayoutParams;
+import android.widget.FrameLayout;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StyleRes;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+import androidx.core.view.AccessibilityDelegateCompat;
+import androidx.core.view.OnApplyWindowInsetsListener;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.core.view.WindowInsetsControllerCompat;
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import com.google.android.material.internal.EdgeToEdgeUtils;
+import com.google.android.material.motion.MaterialBackOrchestrator;
+import com.google.android.material.shape.MaterialShapeDrawable;
+
+/**
+ * Base class for {@link android.app.Dialog}s styled as a bottom sheet.
+ *
+ * Edge to edge window flags are automatically applied if the {@link
+ * android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is
+ * true. These can be set in the theme that is passed to the constructor, or will be taken from the
+ * theme of the context (ie. your application or activity theme).
+ *
+ *
In edge to edge mode, padding will be added automatically to the top when sliding under the
+ * status bar. Padding can be applied automatically to the left, right, or bottom if any of
+ * `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or
+ * `paddingRightSystemWindowInsets` are set to true in the style.
+ *
+ * MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior
+ */
+public class BackportBottomSheetDialog extends AppCompatDialog {
+
+ private BackportBottomSheetBehavior behavior;
+
+ private FrameLayout container;
+ private CoordinatorLayout coordinator;
+ private FrameLayout bottomSheet;
+
+ boolean dismissWithAnimation;
+
+ boolean cancelable = true;
+ private boolean canceledOnTouchOutside = true;
+ private boolean canceledOnTouchOutsideSet;
+ private EdgeToEdgeCallback edgeToEdgeCallback;
+ private boolean edgeToEdgeEnabled;
+ @Nullable private MaterialBackOrchestrator backOrchestrator;
+
+ public BackportBottomSheetDialog(@NonNull Context context) {
+ this(context, 0);
+
+ edgeToEdgeEnabled =
+ getContext()
+ .getTheme()
+ .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
+ .getBoolean(0, false);
+ }
+
+ public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) {
+ super(context, getThemeResId(context, theme));
+ // We hide the title bar for any style configuration. Otherwise, there will be a gap
+ // above the bottom sheet when it is expanded.
+ supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
+
+ edgeToEdgeEnabled =
+ getContext()
+ .getTheme()
+ .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
+ .getBoolean(0, false);
+ }
+
+ protected BackportBottomSheetDialog(
+ @NonNull Context context, boolean cancelable, OnCancelListener cancelListener) {
+ super(context, cancelable, cancelListener);
+ supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
+ this.cancelable = cancelable;
+
+ edgeToEdgeEnabled =
+ getContext()
+ .getTheme()
+ .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
+ .getBoolean(0, false);
+ }
+
+ @Override
+ public void setContentView(@LayoutRes int layoutResId) {
+ super.setContentView(wrapInBottomSheet(layoutResId, null, null));
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Window window = getWindow();
+ if (window != null) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ // The status bar should always be transparent because of the window animation.
+ window.setStatusBarColor(0);
+
+ window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+ if (VERSION.SDK_INT < VERSION_CODES.M) {
+ // It can be transparent for API 23 and above because we will handle switching the status
+ // bar icons to light or dark as appropriate. For API 21 and API 22 we just set the
+ // translucent status bar.
+ window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS);
+ }
+ }
+ window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
+ }
+ }
+
+ @Override
+ public void setContentView(View view) {
+ super.setContentView(wrapInBottomSheet(0, view, null));
+ }
+
+ @Override
+ public void setContentView(View view, ViewGroup.LayoutParams params) {
+ super.setContentView(wrapInBottomSheet(0, view, params));
+ }
+
+ @Override
+ public void setCancelable(boolean cancelable) {
+ super.setCancelable(cancelable);
+ if (this.cancelable != cancelable) {
+ this.cancelable = cancelable;
+ if (behavior != null) {
+ behavior.setHideable(cancelable);
+ }
+ if (getWindow() != null) {
+ updateListeningForBackCallbacks();
+ }
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
+ behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED);
+ }
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ Window window = getWindow();
+ if (window != null) {
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ // If the navigation bar is transparent at all the BottomSheet should be edge to edge.
+ boolean drawEdgeToEdge =
+ edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255;
+ if (container != null) {
+ container.setFitsSystemWindows(!drawEdgeToEdge);
+ }
+ if (coordinator != null) {
+ coordinator.setFitsSystemWindows(!drawEdgeToEdge);
+ }
+ WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge);
+ }
+ if (edgeToEdgeCallback != null) {
+ edgeToEdgeCallback.setWindow(window);
+ }
+ }
+
+ updateListeningForBackCallbacks();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ if (edgeToEdgeCallback != null) {
+ edgeToEdgeCallback.setWindow(null);
+ }
+
+ if (backOrchestrator != null) {
+ backOrchestrator.stopListeningForBackCallbacks();
+ }
+ }
+
+ /**
+ * This function can be called from a few different use cases, including Swiping the dialog down
+ * or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc...
+ *
+ * The default animation to dismiss this dialog is a fade-out transition through a
+ * windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the
+ * BottomSheet animation instead.
+ *
+ *
If this function is called from a swipe down interaction, or dismissWithAnimation is false,
+ * then keep the default behavior.
+ *
+ *
Else, since this is a terminal event which will finish this dialog, we override the attached
+ * {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link
+ * BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before
+ * canceling this dialog.
+ */
+ @Override
+ public void cancel() {
+ BackportBottomSheetBehavior behavior = getBehavior();
+
+ if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
+ super.cancel();
+ } else {
+ behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
+ }
+ }
+
+ @Override
+ public void setCanceledOnTouchOutside(boolean cancel) {
+ super.setCanceledOnTouchOutside(cancel);
+ if (cancel && !cancelable) {
+ cancelable = true;
+ }
+ canceledOnTouchOutside = cancel;
+ canceledOnTouchOutsideSet = true;
+ }
+
+ @NonNull
+ public BackportBottomSheetBehavior getBehavior() {
+ if (behavior == null) {
+ // The content hasn't been set, so the behavior doesn't exist yet. Let's create it.
+ ensureContainerAndBehavior();
+ }
+ return behavior;
+ }
+
+ /**
+ * Set to perform the swipe down animation when dismissing instead of the window animation for the
+ * dialog.
+ *
+ * @param dismissWithAnimation True if swipe down animation should be used when dismissing.
+ */
+ public void setDismissWithAnimation(boolean dismissWithAnimation) {
+ this.dismissWithAnimation = dismissWithAnimation;
+ }
+
+ /**
+ * Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than
+ * the window animation for the dialog.
+ */
+ public boolean getDismissWithAnimation() {
+ return dismissWithAnimation;
+ }
+
+ /** Returns if edge to edge behavior is enabled for this dialog. */
+ public boolean getEdgeToEdgeEnabled() {
+ return edgeToEdgeEnabled;
+ }
+
+ /** Creates the container layout which must exist to find the behavior */
+ private FrameLayout ensureContainerAndBehavior() {
+ if (container == null) {
+ container =
+ (FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null);
+
+ coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
+ bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet);
+
+ // MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior
+ behavior = BackportBottomSheetBehavior.from(bottomSheet);
+ behavior.addBottomSheetCallback(bottomSheetCallback);
+ behavior.setHideable(cancelable);
+
+ backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet);
+ }
+ return container;
+ }
+
+ private View wrapInBottomSheet(
+ int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) {
+ ensureContainerAndBehavior();
+ CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
+ if (layoutResId != 0 && view == null) {
+ view = getLayoutInflater().inflate(layoutResId, coordinator, false);
+ }
+
+ if (edgeToEdgeEnabled) {
+ ViewCompat.setOnApplyWindowInsetsListener(
+ bottomSheet,
+ new OnApplyWindowInsetsListener() {
+ @Override
+ public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) {
+ if (edgeToEdgeCallback != null) {
+ behavior.removeBottomSheetCallback(edgeToEdgeCallback);
+ }
+
+ if (insets != null) {
+ edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets);
+ edgeToEdgeCallback.setWindow(getWindow());
+ behavior.addBottomSheetCallback(edgeToEdgeCallback);
+ }
+
+ return insets;
+ }
+ });
+ }
+
+ bottomSheet.removeAllViews();
+ if (params == null) {
+ bottomSheet.addView(view);
+ } else {
+ bottomSheet.addView(view, params);
+ }
+ // We treat the CoordinatorLayout as outside the dialog though it is technically inside
+ coordinator
+ .findViewById(R.id.touch_outside)
+ .setOnClickListener(
+ new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) {
+ cancel();
+ }
+ }
+ });
+ // Handle accessibility events
+ ViewCompat.setAccessibilityDelegate(
+ bottomSheet,
+ new AccessibilityDelegateCompat() {
+ @Override
+ public void onInitializeAccessibilityNodeInfo(
+ View host, @NonNull AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(host, info);
+ if (cancelable) {
+ info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS);
+ info.setDismissable(true);
+ } else {
+ info.setDismissable(false);
+ }
+ }
+
+ @Override
+ public boolean performAccessibilityAction(View host, int action, Bundle args) {
+ if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) {
+ cancel();
+ return true;
+ }
+ return super.performAccessibilityAction(host, action, args);
+ }
+ });
+ bottomSheet.setOnTouchListener(
+ new View.OnTouchListener() {
+ @Override
+ public boolean onTouch(View view, MotionEvent event) {
+ // Consume the event and prevent it from falling through
+ return true;
+ }
+ });
+ return container;
+ }
+
+ private void updateListeningForBackCallbacks() {
+ if (backOrchestrator == null) {
+ return;
+ }
+ if (cancelable) {
+ backOrchestrator.startListeningForBackCallbacks();
+ } else {
+ backOrchestrator.stopListeningForBackCallbacks();
+ }
+ }
+
+ boolean shouldWindowCloseOnTouchOutside() {
+ if (!canceledOnTouchOutsideSet) {
+ TypedArray a =
+ getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside});
+ canceledOnTouchOutside = a.getBoolean(0, true);
+ a.recycle();
+ canceledOnTouchOutsideSet = true;
+ }
+ return canceledOnTouchOutside;
+ }
+
+ private static int getThemeResId(@NonNull Context context, int themeId) {
+ if (themeId == 0) {
+ // If the provided theme is 0, then retrieve the dialogTheme from our theme
+ TypedValue outValue = new TypedValue();
+ if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) {
+ themeId = outValue.resourceId;
+ } else {
+ // bottomSheetDialogTheme is not provided; we default to our light theme
+ themeId = R.style.Theme_Design_Light_BottomSheetDialog;
+ }
+ }
+ return themeId;
+ }
+
+ void removeDefaultCallback() {
+ behavior.removeBottomSheetCallback(bottomSheetCallback);
+ }
+
+ @NonNull
+ private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
+ new BackportBottomSheetBehavior.BottomSheetCallback() {
+ @Override
+ public void onStateChanged(
+ @NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) {
+ if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
+ cancel();
+ }
+ }
+
+ @Override
+ public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
+ };
+
+ private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
+
+ @Nullable private final Boolean lightBottomSheet;
+ @NonNull private final WindowInsetsCompat insetsCompat;
+
+ @Nullable private Window window;
+ private boolean lightStatusBar;
+
+ private EdgeToEdgeCallback(
+ @NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) {
+ this.insetsCompat = insetsCompat;
+
+ // Try to find the background color to automatically change the status bar icons so they will
+ // still be visible when the bottomsheet slides underneath the status bar.
+ ColorStateList backgroundTint;
+ MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable();
+ if (msd != null) {
+ backgroundTint = msd.getFillColor();
+ } else {
+ backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet);
+ }
+
+ if (backgroundTint != null) {
+ // First check for a tint
+ lightBottomSheet = isColorLight(backgroundTint.getDefaultColor());
+ } else if (bottomSheet.getBackground() instanceof ColorDrawable) {
+ // Then check for the background color
+ lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor());
+ } else {
+ // Otherwise don't change the status bar color
+ lightBottomSheet = null;
+ }
+ }
+
+ @Override
+ public void onStateChanged(@NonNull View bottomSheet, int newState) {
+ setPaddingForPosition(bottomSheet);
+ }
+
+ @Override
+ public void onSlide(@NonNull View bottomSheet, float slideOffset) {
+ setPaddingForPosition(bottomSheet);
+ }
+
+ @Override
+ void onLayout(@NonNull View bottomSheet) {
+ setPaddingForPosition(bottomSheet);
+ }
+
+ void setWindow(@Nullable Window window) {
+ if (this.window == window) {
+ return;
+ }
+ this.window = window;
+ if (window != null) {
+ WindowInsetsControllerCompat insetsController =
+ WindowCompat.getInsetsController(window, window.getDecorView());
+ lightStatusBar = insetsController.isAppearanceLightStatusBars();
+ }
+ }
+
+ private void setPaddingForPosition(View bottomSheet) {
+ if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) {
+ // If the bottomsheet is light, we should set light status bar so the icons are visible
+ // since the bottomsheet is now under the status bar.
+ if (window != null) {
+ EdgeToEdgeUtils.setLightStatusBar(
+ window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet);
+ }
+ // Smooth transition into status bar when drawing edge to edge.
+ bottomSheet.setPadding(
+ bottomSheet.getPaddingLeft(),
+ (insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()),
+ bottomSheet.getPaddingRight(),
+ bottomSheet.getPaddingBottom());
+ } else if (bottomSheet.getTop() != 0) {
+ // Reset the status bar icons to the original color because the bottomsheet is not under the
+ // status bar.
+ if (window != null) {
+ EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar);
+ }
+ bottomSheet.setPadding(
+ bottomSheet.getPaddingLeft(),
+ 0,
+ bottomSheet.getPaddingRight(),
+ bottomSheet.getPaddingBottom());
+ }
+ }
+ }
+
+ /**
+ * @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead
+ */
+ @Deprecated
+ public static void setLightStatusBar(@NonNull View view, boolean isLight) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ int flags = view.getSystemUiVisibility();
+ if (isLight) {
+ flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
+ } else {
+ flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
+ }
+ view.setSystemUiVisibility(flags);
+ }
+ }
+}
diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java
new file mode 100644
index 000000000..eead66daa
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2015 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.bottomsheet;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatDialogFragment;
+import android.view.View;
+import androidx.annotation.LayoutRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows
+ * a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog.
+ */
+public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment {
+
+ /**
+ * Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the
+ * BottomSheet is hidden and onStateChanged() is called.
+ */
+ private boolean waitingForDismissAllowingStateLoss;
+
+ public BackportBottomSheetDialogFragment() {}
+
+ @SuppressLint("ValidFragment")
+ public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) {
+ super(contentLayoutId);
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ return new BackportBottomSheetDialog(getContext(), getTheme());
+ }
+
+ @Override
+ public void dismiss() {
+ if (!tryDismissWithAnimation(false)) {
+ super.dismiss();
+ }
+ }
+
+ @Override
+ public void dismissAllowingStateLoss() {
+ if (!tryDismissWithAnimation(true)) {
+ super.dismissAllowingStateLoss();
+ }
+ }
+
+ /**
+ * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
+ * false otherwise.
+ */
+ private boolean tryDismissWithAnimation(boolean allowingStateLoss) {
+ Dialog baseDialog = getDialog();
+ if (baseDialog instanceof BackportBottomSheetDialog) {
+ BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog;
+ BackportBottomSheetBehavior> behavior = dialog.getBehavior();
+ if (behavior.isHideable() && dialog.getDismissWithAnimation()) {
+ dismissWithAnimation(behavior, allowingStateLoss);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void dismissWithAnimation(
+ @NonNull BackportBottomSheetBehavior> behavior, boolean allowingStateLoss) {
+ waitingForDismissAllowingStateLoss = allowingStateLoss;
+
+ if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
+ dismissAfterAnimation();
+ } else {
+ if (getDialog() instanceof BackportBottomSheetDialog) {
+ ((BackportBottomSheetDialog) getDialog()).removeDefaultCallback();
+ }
+ behavior.addBottomSheetCallback(new BottomSheetDismissCallback());
+ behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
+ }
+ }
+
+ private void dismissAfterAnimation() {
+ if (waitingForDismissAllowingStateLoss) {
+ super.dismissAllowingStateLoss();
+ } else {
+ super.dismiss();
+ }
+ }
+
+ private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
+
+ @Override
+ public void onStateChanged(@NonNull View bottomSheet, int newState) {
+ if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
+ dismissAfterAnimation();
+ }
+ }
+
+ @Override
+ public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
index df737e4c2..ebcffb5e2 100644
--- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt
+++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
@@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
+import timber.log.Timber
/**
* A simple, rational music player for android.
@@ -44,6 +45,10 @@ class Auxio : Application() {
override fun onCreate() {
super.onCreate()
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+
// Migrate any settings that may have changed in an app update.
imageSettings.migrate()
playbackSettings.migrate()
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 2d510ea36..297bad95c 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -27,8 +27,6 @@ import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
-import androidx.navigation.NavController
-import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR
@@ -40,6 +38,7 @@ import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.detail.DetailViewModel
+import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel
@@ -49,7 +48,9 @@ import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
+import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.ui.ViewBindingFragment
+import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
@@ -67,9 +68,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
@AndroidEntryPoint
class MainFragment :
- ViewBindingFragment(),
- ViewTreeObserver.OnPreDrawListener,
- NavController.OnDestinationChangedListener {
+ ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener {
private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels()
@@ -77,9 +76,9 @@ class MainFragment :
private var sheetBackCallback: SheetBackPressedCallback? = null
private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null
+ private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
- private var initialNavDestinationChange = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -111,6 +110,8 @@ class MainFragment :
val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
+ selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
+
// --- UI SETUP ---
val context = requireActivity()
@@ -150,6 +151,11 @@ class MainFragment :
}
// --- VIEWMODEL SETUP ---
+ // This has to be done here instead of the playback panel to make sure that it's prioritized
+ // by StateFlow over any detail fragment.
+ // FIXME: This is a consequence of sharing events across several consumers. There has to be
+ // a better way of doing this.
+ collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
@@ -162,8 +168,8 @@ class MainFragment :
val binding = requireBinding()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
- initialNavDestinationChange = false
- binding.exploreNavHost.findNavController().addOnDestinationChangedListener(this)
+ requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
+ .attach(binding.exploreNavHost.findNavController())
// Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively.
binding.playbackSheet.viewTreeObserver.addOnPreDrawListener(this@MainFragment)
@@ -184,7 +190,8 @@ class MainFragment :
override fun onStop() {
super.onStop()
val binding = requireBinding()
- binding.exploreNavHost.findNavController().removeOnDestinationChangedListener(this)
+ requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
+ .release(binding.exploreNavHost.findNavController())
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
}
@@ -193,6 +200,7 @@ class MainFragment :
sheetBackCallback = null
detailBackCallback = null
selectionBackCallback = null
+ selectionNavigationListener = null
}
override fun onPreDraw(): Boolean {
@@ -286,19 +294,18 @@ class MainFragment :
return true
}
- override fun onDestinationChanged(
- controller: NavController,
- destination: NavDestination,
- arguments: Bundle?
- ) {
- // Drop the initial call by NavController that simply provides us with the current
- // destination. This would cause the selection state to be lost every time the device
- // rotates.
- if (!initialNavDestinationChange) {
- initialNavDestinationChange = true
- return
+ private fun handleShow(show: Show?) {
+ when (show) {
+ is Show.SongAlbumDetails,
+ is Show.ArtistDetails,
+ is Show.AlbumDetails -> playbackModel.openMain()
+ is Show.SongDetails,
+ is Show.SongArtistDecision,
+ is Show.AlbumArtistDecision,
+ is Show.GenreDetails,
+ is Show.PlaylistDetails,
+ null -> {}
}
- listModel.dropSelection()
}
private fun handleShowOuter(outer: Outer?) {
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
index 7227d9aa6..3fd4a6963 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
@@ -101,7 +101,7 @@ class AlbumDetailFragment :
setNavigationOnClickListener { findNavController().navigateUp() }
overrideOnOverflowMenuClick {
listModel.openMenu(
- R.menu.item_detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
+ R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
}
@@ -145,7 +145,7 @@ class AlbumDetailFragment :
}
override fun onOpenMenu(item: Song) {
- listModel.openMenu(R.menu.item_album_song, item, detailModel.playInAlbumWith)
+ listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
}
override fun onPlay() {
@@ -243,6 +243,7 @@ class AlbumDetailFragment :
when (menu) {
is Menu.ForSong -> AlbumDetailFragmentDirections.openSongMenu(menu.parcel)
is Menu.ForAlbum -> AlbumDetailFragmentDirections.openAlbumMenu(menu.parcel)
+ is Menu.ForSelection -> AlbumDetailFragmentDirections.openSelectionMenu(menu.parcel)
is Menu.ForArtist,
is Menu.ForGenre,
is Menu.ForPlaylist -> error("Unexpected menu $menu")
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
index c209a1a05..b0bc09386 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
@@ -100,7 +100,7 @@ class ArtistDetailFragment :
setOnMenuItemClickListener(this@ArtistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
- R.menu.item_detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
+ R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
}
}
@@ -152,9 +152,8 @@ class ArtistDetailFragment :
override fun onOpenMenu(item: Music) {
when (item) {
- is Song ->
- listModel.openMenu(R.menu.item_artist_song, item, detailModel.playInArtistWith)
- is Album -> listModel.openMenu(R.menu.item_artist_album, item)
+ is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
+ is Album -> listModel.openMenu(R.menu.artist_album, item)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
}
@@ -222,8 +221,16 @@ class ArtistDetailFragment :
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
}
}
- is Show.SongArtistDecision,
- is Show.AlbumArtistDecision,
+ is Show.SongArtistDecision -> {
+ logD("Navigating to artist choices for ${show.song}")
+ findNavController()
+ .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
+ }
+ is Show.AlbumArtistDecision -> {
+ logD("Navigating to artist choices for ${show.album}")
+ findNavController()
+ .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
+ }
is Show.GenreDetails,
is Show.PlaylistDetails -> {
error("Unexpected show command $show")
@@ -239,6 +246,8 @@ class ArtistDetailFragment :
is Menu.ForSong -> ArtistDetailFragmentDirections.openSongMenu(menu.parcel)
is Menu.ForAlbum -> ArtistDetailFragmentDirections.openAlbumMenu(menu.parcel)
is Menu.ForArtist -> ArtistDetailFragmentDirections.openArtistMenu(menu.parcel)
+ is Menu.ForSelection ->
+ ArtistDetailFragmentDirections.openSelectionMenu(menu.parcel)
is Menu.ForGenre,
is Menu.ForPlaylist -> error("Unexpected menu $menu")
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
index 8b9cf5a68..522ebbfa6 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
@@ -98,7 +98,7 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@GenreDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
- R.menu.item_detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
+ R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
}
}
@@ -150,8 +150,8 @@ class GenreDetailFragment :
override fun onOpenMenu(item: Music) {
when (item) {
- is Artist -> listModel.openMenu(R.menu.item_parent, item)
- is Song -> listModel.openMenu(R.menu.item_song, item, detailModel.playInGenreWith)
+ is Artist -> listModel.openMenu(R.menu.parent, item)
+ is Song -> listModel.openMenu(R.menu.song, item, detailModel.playInGenreWith)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
}
@@ -240,6 +240,7 @@ class GenreDetailFragment :
is Menu.ForSong -> GenreDetailFragmentDirections.openSongMenu(menu.parcel)
is Menu.ForArtist -> GenreDetailFragmentDirections.openArtistMenu(menu.parcel)
is Menu.ForGenre -> GenreDetailFragmentDirections.openGenreMenu(menu.parcel)
+ is Menu.ForSelection -> GenreDetailFragmentDirections.openSelectionMenu(menu.parcel)
is Menu.ForAlbum,
is Menu.ForPlaylist -> error("Unexpected menu $menu")
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt
index 9a2c02777..ed460bc33 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt
@@ -20,9 +20,8 @@ package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
+import android.view.MenuItem
import androidx.fragment.app.activityViewModels
-import androidx.navigation.NavController
-import androidx.navigation.NavDestination
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
@@ -51,6 +50,7 @@ import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@@ -68,8 +68,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class PlaylistDetailFragment :
ListFragment(),
DetailHeaderAdapter.Listener,
- PlaylistDetailListAdapter.Listener,
- NavController.OnDestinationChangedListener {
+ PlaylistDetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
@@ -80,7 +79,7 @@ class PlaylistDetailFragment :
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
- private var initialNavDestinationChange = false
+ private var editNavigationListener: DialogAwareNavigationListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -98,14 +97,15 @@ class PlaylistDetailFragment :
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
+ editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
+
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
- R.menu.item_detail_playlist,
- unlikelyToBeNull(detailModel.currentPlaylist.value))
+ R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
@@ -148,17 +148,31 @@ class PlaylistDetailFragment :
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ if (super.onMenuItemClick(item)) {
+ return true
+ }
+
+ if (item.itemId == R.id.action_save) {
+ detailModel.savePlaylistEdit()
+ return true
+ }
+
+ return false
+ }
+
override fun onStart() {
super.onStart()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
- initialNavDestinationChange = false
- findNavController().addOnDestinationChangedListener(this)
+ requireNotNull(editNavigationListener) { "NavigationListener was not available" }
+ .attach(findNavController())
}
override fun onStop() {
super.onStop()
- findNavController().removeOnDestinationChangedListener(this)
+ requireNotNull(editNavigationListener) { "NavigationListener was not available" }
+ .release(findNavController())
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@@ -169,26 +183,7 @@ class PlaylistDetailFragment :
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.playlistSongInstructions.consume()
- }
-
- override fun onDestinationChanged(
- controller: NavController,
- destination: NavDestination,
- arguments: Bundle?
- ) {
- // Drop the initial call by NavController that simply provides us with the current
- // destination. This would cause the selection state to be lost every time the device
- // rotates.
- if (!initialNavDestinationChange) {
- initialNavDestinationChange = true
- return
- }
- if (destination.id != R.id.playlist_detail_fragment &&
- destination.id != R.id.playlist_song_sort_dialog) {
- // Drop any pending playlist edits when navigating away. This could actually happen
- // if the user is quick enough.
- detailModel.dropPlaylistEdit()
- }
+ editNavigationListener = null
}
override fun onRealClick(item: Song) {
@@ -200,7 +195,7 @@ class PlaylistDetailFragment :
}
override fun onOpenMenu(item: Song) {
- listModel.openMenu(R.menu.item_playlist_song, item, detailModel.playInPlaylistWith)
+ listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
}
override fun onPlay() {
@@ -302,6 +297,8 @@ class PlaylistDetailFragment :
is Menu.ForSong -> PlaylistDetailFragmentDirections.openSongMenu(menu.parcel)
is Menu.ForPlaylist ->
PlaylistDetailFragmentDirections.openPlaylistMenu(menu.parcel)
+ is Menu.ForSelection ->
+ PlaylistDetailFragmentDirections.openSelectionMenu(menu.parcel)
is Menu.ForArtist,
is Menu.ForAlbum,
is Menu.ForGenre -> error("Unexpected menu $menu")
diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt
new file mode 100644
index 000000000..e88ed1175
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * ErrorDetailsDialog.kt is part of Auxio.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.home
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import androidx.appcompat.app.AlertDialog
+import androidx.navigation.fragment.navArgs
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
+import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
+import org.oxycblt.auxio.util.getSystemServiceCompat
+import org.oxycblt.auxio.util.openInBrowser
+import org.oxycblt.auxio.util.showToast
+
+/**
+ * A dialog that shows a stack trace for a music loading error.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ *
+ * TODO: Extend to other errors
+ */
+class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() {
+ private val args: ErrorDetailsDialogArgs by navArgs()
+ private var clipboardManager: ClipboardManager? = null
+
+ override fun onConfigDialog(builder: AlertDialog.Builder) {
+ builder
+ .setTitle(R.string.lbl_error_info)
+ .setPositiveButton(R.string.lbl_report) { _, _ ->
+ requireContext().openInBrowser(LINK_ISSUES)
+ }
+ .setNegativeButton(R.string.lbl_cancel, null)
+ }
+
+ override fun onCreateBinding(inflater: LayoutInflater) =
+ DialogErrorDetailsBinding.inflate(inflater)
+
+ override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
+ super.onBindingCreated(binding, savedInstanceState)
+
+ clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class)
+
+ // --- UI SETUP ---
+ binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n')
+ binding.errorCopy.setOnClickListener { copyStackTrace() }
+ }
+
+ override fun onDestroyBinding(binding: DialogErrorDetailsBinding) {
+ super.onDestroyBinding(binding)
+ clipboardManager = null
+ }
+
+ private fun copyStackTrace() {
+ requireNotNull(clipboardManager) { "Clipboard was unavailable" }
+ .setPrimaryClip(
+ ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString()))
+ // A copy notice is shown by the system from Android 13 onwards
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ requireContext().showToast(R.string.lbl_copied)
+ }
+ }
+
+ private companion object {
+ /** The URL to the bug report issue form */
+ const val LINK_ISSUES =
+ "https://github.com/OxygenCobalt/Auxio/issues/new" +
+ "?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml"
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt
index a03adccfd..c3cd4a82f 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt
@@ -51,7 +51,7 @@ constructor(
// Apply the new configuration possibly set in flipTo. This should occur even if
// a flip was canceled by a hide.
pendingConfig?.run {
- this@FlipFloatingActionButton.logD("Applying pending configuration")
+ logD("Applying pending configuration")
setImageResource(iconRes)
contentDescription = context.getString(contentDescriptionRes)
setOnClickListener(clickListener)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index 66de36045..01558611d 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -330,7 +330,7 @@ class HomeFragment :
}
}
- private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
+ private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
logD("Received ok response")
binding.homeFab.show()
@@ -342,13 +342,13 @@ class HomeFragment :
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
+ binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
logD("Showing permission prompt")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
- binding.homeIndexingAction.apply {
- visibility = View.VISIBLE
+ binding.homeIndexingTry.apply {
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
@@ -357,26 +357,34 @@ class HomeFragment :
.launch(PERMISSION_READ_AUDIO)
}
}
+ binding.homeIndexingMore.visibility = View.GONE
}
is NoMusicException -> {
logD("Showing no music error")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
+ binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
+ binding.homeIndexingMore.visibility = View.GONE
}
else -> {
logD("Showing generic error")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
- binding.homeIndexingAction.apply {
+ binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
+ binding.homeIndexingMore.apply {
+ visibility = View.VISIBLE
+ setOnClickListener {
+ findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
+ }
+ }
}
}
}
@@ -385,7 +393,7 @@ class HomeFragment :
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
- binding.homeIndexingAction.visibility = View.INVISIBLE
+ binding.homeIndexingActions.visibility = View.INVISIBLE
when (progress) {
is IndexingProgress.Indeterminate -> {
@@ -501,6 +509,7 @@ class HomeFragment :
is Menu.ForArtist -> HomeFragmentDirections.openArtistMenu(menu.parcel)
is Menu.ForGenre -> HomeFragmentDirections.openGenreMenu(menu.parcel)
is Menu.ForPlaylist -> HomeFragmentDirections.openPlaylistMenu(menu.parcel)
+ is Menu.ForSelection -> HomeFragmentDirections.openSelectionMenu(menu.parcel)
}
findNavController().navigateSafe(directions)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index a7c63a455..74c942dae 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -140,7 +140,7 @@ class AlbumListFragment :
}
override fun onOpenMenu(item: Album) {
- listModel.openMenu(R.menu.item_album, item)
+ listModel.openMenu(R.menu.album, item)
}
private fun updateAlbums(albums: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index 84834cb74..7dc885308 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -116,7 +116,7 @@ class ArtistListFragment :
}
override fun onOpenMenu(item: Artist) {
- listModel.openMenu(R.menu.item_parent, item)
+ listModel.openMenu(R.menu.parent, item)
}
private fun updateArtists(artists: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index a39c0ee2d..3307fa721 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -115,7 +115,7 @@ class GenreListFragment :
}
override fun onOpenMenu(item: Genre) {
- listModel.openMenu(R.menu.item_parent, item)
+ listModel.openMenu(R.menu.parent, item)
}
private fun updateGenres(genres: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt
index d8a7ac175..4228c872a 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt
@@ -113,7 +113,7 @@ class PlaylistListFragment :
}
override fun onOpenMenu(item: Playlist) {
- listModel.openMenu(R.menu.item_playlist, item)
+ listModel.openMenu(R.menu.playlist, item)
}
private fun updatePlaylists(playlists: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index fb214b76b..04f9847f1 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -139,7 +139,7 @@ class SongListFragment :
}
override fun onOpenMenu(item: Song) {
- listModel.openMenu(R.menu.item_song, item, homeModel.playWith)
+ listModel.openMenu(R.menu.song, item, homeModel.playWith)
}
private fun updateSongs(songs: List) {
diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt
index ed6536fe2..e223f439b 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt
@@ -109,6 +109,22 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
_selected.value = selected
}
+ /**
+ * Clear the current selection and return it.
+ *
+ * @return A list of [Song]s collated from each item selected.
+ */
+ fun peekSelection() =
+ _selected.value.flatMap {
+ when (it) {
+ is Song -> listOf(it)
+ is Album -> listSettings.albumSongSort.songs(it.songs)
+ is Artist -> listSettings.artistSongSort.songs(it.songs)
+ is Genre -> listSettings.genreSongSort.songs(it.songs)
+ is Playlist -> it.songs
+ }
+ }
+
/**
* Clear the current selection and return it.
*
@@ -116,17 +132,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
*/
fun takeSelection(): List {
logD("Taking selection")
- return _selected.value
- .flatMap {
- when (it) {
- is Song -> listOf(it)
- is Album -> listSettings.albumSongSort.songs(it.songs)
- is Artist -> listSettings.artistSongSort.songs(it.songs)
- is Genre -> listSettings.genreSongSort.songs(it.songs)
- is Playlist -> it.songs
- }
- }
- .also { _selected.value = listOf() }
+ return peekSelection().also { _selected.value = listOf() }
}
/**
@@ -201,6 +207,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
openImpl(Menu.ForPlaylist(menuRes, playlist))
}
+ /**
+ * Open a menu for a [Song] selection. This is not a popup menu, instead actually a dialog of
+ * menu options with additional information.
+ *
+ * @param menuRes The resource of the menu to use.
+ * @param songs The [Song] selection to show.
+ */
+ fun openMenu(@MenuRes menuRes: Int, songs: List) {
+ logD("Opening menu for ${songs.size} songs")
+ openImpl(Menu.ForSelection(menuRes, songs))
+ }
+
private fun openImpl(menu: Menu) {
val existing = _menu.flow.value
if (existing != null) {
diff --git a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt
index fd461d222..69b58ac5f 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt
@@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
-import org.oxycblt.auxio.util.share
+import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.showToast
/**
@@ -48,6 +48,9 @@ abstract class SelectionFragment :
// Add cancel and menu item listeners to manage what occurs with the selection.
setNavigationOnClickListener { listModel.dropSelection() }
setOnMenuItemClickListener(this@SelectionFragment)
+ overrideOnOverflowMenuClick {
+ listModel.openMenu(R.menu.selection, listModel.peekSelection())
+ }
}
}
@@ -67,23 +70,6 @@ abstract class SelectionFragment :
musicModel.addToPlaylist(listModel.takeSelection())
true
}
- R.id.action_selection_queue_add -> {
- playbackModel.addToQueue(listModel.takeSelection())
- requireContext().showToast(R.string.lng_queue_added)
- true
- }
- R.id.action_selection_play -> {
- playbackModel.play(listModel.takeSelection())
- true
- }
- R.id.action_selection_shuffle -> {
- playbackModel.shuffle(listModel.takeSelection())
- true
- }
- R.id.action_selection_share -> {
- requireContext().share(listModel.takeSelection())
- true
- }
else -> false
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt
index fc388cd36..24581b5d2 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt
@@ -99,4 +99,11 @@ sealed interface Menu {
@Parcelize data class Parcel(val res: Int, val playlistUid: Music.UID) : Menu.Parcel
}
+
+ class ForSelection(@MenuRes override val res: Int, val songs: List) : Menu {
+ override val parcel: Parcel
+ get() = Parcel(res, songs.map { it.uid })
+
+ @Parcelize data class Parcel(val res: Int, val songUids: List) : Menu.Parcel
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt
index 9abf34133..a7eef2392 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt
@@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast
@@ -78,10 +79,10 @@ class SongMenuDialogFragment : MenuDialogFragment() {
playbackModel.addToQueue(menu.song)
requireContext().showToast(R.string.lng_queue_added)
}
+ R.id.action_playlist_add -> musicModel.addToPlaylist(menu.song)
R.id.action_artist_details -> detailModel.showArtist(menu.song)
R.id.action_album_details -> detailModel.showAlbum(menu.song.album)
R.id.action_share -> requireContext().share(menu.song)
- R.id.action_playlist_add -> musicModel.addToPlaylist(menu.song)
R.id.action_detail -> detailModel.showSong(menu.song)
else -> error("Unexpected menu item selected $item")
}
@@ -321,3 +322,51 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() {
}
}
}
+
+/**
+ * [MenuDialogFragment] implementation for a [Song] selection.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+@AndroidEntryPoint
+class SelectionMenuDialogFragment : MenuDialogFragment() {
+ override val menuModel: MenuViewModel by activityViewModels()
+ override val listModel: ListViewModel by activityViewModels()
+ private val musicModel: MusicViewModel by activityViewModels()
+ private val playbackModel: PlaybackViewModel by activityViewModels()
+ private val args: SelectionMenuDialogFragmentArgs by navArgs()
+
+ override val parcel
+ get() = args.parcel
+
+ // Nothing to disable in song menus.
+ override fun getDisabledItemIds(menu: Menu.ForSelection) = setOf()
+
+ override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForSelection) {
+ binding.menuCover.bind(
+ menu.songs, getString(R.string.desc_selection_image), R.drawable.ic_song_24)
+ binding.menuType.text = getString(R.string.lbl_selection)
+ binding.menuName.text =
+ requireContext().getPlural(R.plurals.fmt_song_count, menu.songs.size)
+ binding.menuInfo.text = menu.songs.sumOf { it.durationMs }.formatDurationMs(true)
+ }
+
+ override fun onClick(item: MenuItem, menu: Menu.ForSelection) {
+ listModel.dropSelection()
+ when (item.itemId) {
+ R.id.action_play -> playbackModel.play(menu.songs)
+ R.id.action_shuffle -> playbackModel.shuffle(menu.songs)
+ R.id.action_play_next -> {
+ playbackModel.playNext(menu.songs)
+ requireContext().showToast(R.string.lng_queue_added)
+ }
+ R.id.action_queue_add -> {
+ playbackModel.addToQueue(menu.songs)
+ requireContext().showToast(R.string.lng_queue_added)
+ }
+ R.id.action_playlist_add -> musicModel.addToPlaylist(menu.songs)
+ R.id.action_share -> requireContext().share(menu.songs)
+ else -> error("Unexpected menu item selected $item")
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt
index 0d5388854..18ff75ccc 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt
@@ -66,6 +66,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
is Menu.ForArtist.Parcel -> unpackArtistParcel(parcel)
is Menu.ForGenre.Parcel -> unpackGenreParcel(parcel)
is Menu.ForPlaylist.Parcel -> unpackPlaylistParcel(parcel)
+ is Menu.ForSelection.Parcel -> unpackSelectionParcel(parcel)
}
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
@@ -94,4 +95,10 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
return Menu.ForPlaylist(parcel.res, playlist)
}
+
+ private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
+ val deviceLibrary = musicRepository.deviceLibrary ?: return null
+ val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
+ return Menu.ForSelection(parcel.res, songs)
+ }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
index a185d5b2f..d4e582660 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
@@ -47,7 +47,7 @@ sealed interface IndexingState {
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
- data class Completed(val error: Throwable?) : IndexingState
+ data class Completed(val error: Exception?) : IndexingState
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
index 6e45c5ae9..662eb49c5 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
@@ -223,7 +223,8 @@ constructor(
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor,
private val deviceLibraryFactory: DeviceLibrary.Factory,
- private val userLibraryFactory: UserLibrary.Factory
+ private val userLibraryFactory: UserLibrary.Factory,
+ private val musicSettings: MusicSettings
) : MusicRepository {
private val updateListeners = mutableListOf()
private val indexingListeners = mutableListOf()
@@ -371,6 +372,7 @@ constructor(
// parallel.
logD("Starting MediaStore query")
emitIndexingProgress(IndexingProgress.Indeterminate)
+
val mediaStoreQueryJob =
worker.scope.async {
val query =
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
index f2930d3ec..488c98126 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
@@ -43,7 +43,7 @@ interface MusicSettings : Settings {
/** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */
- var multiValueSeparators: String
+ var separators: String
/** Whether to enable more advanced sorting by articles and numbers. */
val intelligentSorting: Boolean
@@ -85,7 +85,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
- override var multiValueSeparators: String
+ override var separators: String
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use and more extendable.
get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: ""
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
index d28547239..2e3e8a944 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
@@ -63,9 +63,9 @@ data class CachedSong(
/** @see RawSong */
var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
- val replayGainTrackAdjustment: Float?,
+ val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
- val replayGainAlbumAdjustment: Float?,
+ val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt
index 57e2bdf7c..51dcfcf6c 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt
@@ -32,10 +32,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel
-import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
-import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe
@@ -76,7 +74,7 @@ class AddToPlaylistDialog :
// --- VIEWMODEL SETUP ---
pickerModel.setSongsToAdd(args.songUids)
- collect(musicModel.playlistDecision.flow, ::handleDecision)
+ musicModel.playlistDecision.consume()
collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs)
collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices)
}
@@ -93,26 +91,16 @@ class AddToPlaylistDialog :
}
override fun onNewPlaylist() {
- musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return)
- }
-
- private fun handleDecision(decision: PlaylistDecision?) {
- when (decision) {
- is PlaylistDecision.Add -> {
- logD("Navigated to playlist add dialog")
- musicModel.playlistDecision.consume()
- }
- is PlaylistDecision.New -> {
- logD("Navigating to new playlist dialog")
- findNavController()
- .navigateSafe(
- AddToPlaylistDialogDirections.newPlaylist(
- decision.songs.map { it.uid }.toTypedArray()))
- }
- is PlaylistDecision.Rename,
- is PlaylistDecision.Delete -> error("Unexpected decision $decision")
- null -> {}
- }
+ // TODO: This is a temporary fix. Eventually I want to make this navigate away and
+ // instead have primary fragments launch navigation to the new playlist dialog.
+ // This should be better design (dialog layering is uh... probably not good) and
+ // preserves the existing navigation system.
+ // I could also roll some kind of new playlist textbox into the dialog, but that's
+ // a lot harder.
+ val songs = pickerModel.currentSongsToAdd.value ?: return
+ findNavController()
+ .navigateSafe(
+ AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray()))
}
private fun updatePendingSongs(songs: List?) {
diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt
index cd75ba578..739faba8c 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt
@@ -32,6 +32,8 @@ import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery
+import org.oxycblt.auxio.music.info.Name
+import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -107,7 +109,7 @@ interface DeviceLibrary {
*/
suspend fun create(
rawSongs: Channel,
- processedSongs: Channel
+ processedSongs: Channel,
): DeviceLibraryImpl
}
}
@@ -118,6 +120,9 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
rawSongs: Channel,
processedSongs: Channel
): DeviceLibraryImpl {
+ val nameFactory = Name.Known.Factory.from(musicSettings)
+ val separators = Separators.from(musicSettings)
+
val songGrouping = mutableMapOf()
val albumGrouping = mutableMapOf>()
val artistGrouping = mutableMapOf>()
@@ -127,7 +132,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
// All music information is grouped as it is indexed by other components.
for (rawSong in rawSongs) {
- val song = SongImpl(rawSong, musicSettings)
+ val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from
// causing severe issues elsewhere.
@@ -207,7 +212,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
// Now that all songs are processed, also process albums and group them into their
// respective artists.
- val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, musicSettings) }
+ val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, nameFactory) }
for (album in albums) {
for (rawArtist in album.rawArtists) {
val key = RawArtist.Key(rawArtist)
@@ -243,8 +248,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
}
// Artists and genres do not need to be grouped and can be processed immediately.
- val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, musicSettings) }
- val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, musicSettings) }
+ val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, nameFactory) }
+ val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
index 1d2ce2a26..d9f381ec9 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt
@@ -25,7 +25,6 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType
@@ -36,8 +35,8 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
+import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
-import org.oxycblt.auxio.music.metadata.parseMultiValue
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.toUuidOrNull
@@ -48,10 +47,15 @@ import org.oxycblt.auxio.util.update
* Library-backed implementation of [Song].
*
* @param rawSong The [RawSong] to derive the member data from.
- * @param musicSettings [MusicSettings] to for user parsing configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
+ * @param separators The [Separators] to parse multi-value tags with.
* @author Alexander Capehart (OxygenCobalt)
*/
-class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song {
+class SongImpl(
+ private val rawSong: RawSong,
+ private val nameFactory: Name.Known.Factory,
+ private val separators: Separators
+) : Song {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
@@ -70,10 +74,8 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
update(rawSong.albumArtistNames)
}
override val name =
- Name.Known.from(
- requireNotNull(rawSong.name) { "Invalid raw: No title" },
- rawSong.sortName,
- musicSettings)
+ nameFactory.parse(
+ requireNotNull(rawSong.name) { "Invalid raw: No title" }, rawSong.sortName)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
@@ -95,42 +97,11 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }
+
private var _album: AlbumImpl? = null
override val album: Album
get() = unlikelyToBeNull(_album)
- private val hashCode = 31 * uid.hashCode() + rawSong.hashCode()
-
- override fun hashCode() = hashCode
-
- override fun equals(other: Any?) =
- other is SongImpl && uid == other.uid && rawSong == other.rawSong
-
- override fun toString() = "Song(uid=$uid, name=$name)"
-
- private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
- private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
- private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings)
- private val rawIndividualArtists =
- artistNames.mapIndexed { i, name ->
- RawArtist(
- artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
- name,
- artistSortNames.getOrNull(i))
- }
-
- private val albumArtistMusicBrainzIds =
- rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
- private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings)
- private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings)
- private val rawAlbumArtists =
- albumArtistNames.mapIndexed { i, name ->
- RawArtist(
- albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
- name,
- albumArtistSortNames.getOrNull(i))
- }
-
private val _artists = mutableListOf()
override val artists: List
get() = _artists
@@ -143,40 +114,90 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album].
*/
- val rawAlbum =
- RawAlbum(
- mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" },
- musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
- name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
- sortName = rawSong.albumSortName,
- releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)),
- rawArtists =
- rawAlbumArtists
- .ifEmpty { rawIndividualArtists }
- .distinctBy { it.key }
- .ifEmpty { listOf(RawArtist(null, null)) })
+ val rawAlbum: RawAlbum
/**
* The [RawArtist] instances collated by the [Song]. The artists of the song take priority,
* followed by the album artists. If there are no artists, this field will be a single "unknown"
* [RawArtist]. This can be used to group up [Song]s into an [Artist].
*/
- val rawArtists =
- rawIndividualArtists
- .ifEmpty { rawAlbumArtists }
- .distinctBy { it.key }
- .ifEmpty { listOf(RawArtist()) }
+ val rawArtists: List
/**
* The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names.
*/
- val rawGenres =
- rawSong.genreNames
- .parseId3GenreNames(musicSettings)
- .map { RawGenre(it) }
- .distinctBy { it.key }
- .ifEmpty { listOf(RawGenre()) }
+ val rawGenres: List
+
+ private var hashCode: Int = uid.hashCode()
+
+ init {
+ val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds)
+ val artistNames = separators.split(rawSong.artistNames)
+ val artistSortNames = separators.split(rawSong.artistSortNames)
+ val rawIndividualArtists =
+ artistNames
+ .mapIndexedTo(mutableSetOf()) { i, name ->
+ RawArtist(
+ artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
+ name,
+ artistSortNames.getOrNull(i))
+ }
+ .toList()
+
+ val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
+ val albumArtistNames = separators.split(rawSong.albumArtistNames)
+ val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
+ val rawAlbumArtists =
+ albumArtistNames
+ .mapIndexedTo(mutableSetOf()) { i, name ->
+ RawArtist(
+ albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
+ name,
+ albumArtistSortNames.getOrNull(i))
+ }
+ .toList()
+
+ rawAlbum =
+ RawAlbum(
+ mediaStoreId =
+ requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" },
+ musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
+ name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
+ sortName = rawSong.albumSortName,
+ releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
+ rawArtists =
+ rawAlbumArtists
+ .ifEmpty { rawIndividualArtists }
+ .ifEmpty { listOf(RawArtist()) })
+
+ rawArtists =
+ rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
+
+ val genreNames =
+ (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
+ rawGenres =
+ genreNames
+ .mapTo(mutableSetOf()) { RawGenre(it) }
+ .toList()
+ .ifEmpty { listOf(RawGenre()) }
+
+ hashCode = 31 * rawSong.hashCode()
+ hashCode = 31 * nameFactory.hashCode()
+ }
+
+ override fun hashCode() = hashCode
+
+ // Since equality on public-facing music models is not identical to the tag equality,
+ // we just compare raw instances and how they are interpreted.
+ override fun equals(other: Any?) =
+ other is SongImpl &&
+ uid == other.uid &&
+ nameFactory == other.nameFactory &&
+ separators == other.separators &&
+ rawSong == other.rawSong
+
+ override fun toString() = "Song(uid=$uid, name=$name)"
/**
* Links this [Song] with a parent [Album].
@@ -242,12 +263,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
* Library-backed implementation of [Album].
*
* @param grouping [Grouping] to derive the member data from.
- * @param musicSettings [MusicSettings] to for user parsing configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(
grouping: Grouping,
- musicSettings: MusicSettings,
+ private val nameFactory: Name.Known.Factory
) : Album {
private val rawAlbum = grouping.raw.inner
@@ -261,7 +282,7 @@ class AlbumImpl(
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
- override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
+ override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
@@ -311,13 +332,20 @@ class AlbumImpl(
dateAdded = earliestDateAdded
hashCode = 31 * hashCode + rawAlbum.hashCode()
+ hashCode = 31 * nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
+ // Since equality on public-facing music models is not identical to the tag equality,
+ // we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
- other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
+ other is AlbumImpl &&
+ uid == other.uid &&
+ rawAlbum == other.rawAlbum &&
+ nameFactory == other.nameFactory &&
+ songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
@@ -362,10 +390,13 @@ class AlbumImpl(
* Library-backed implementation of [Artist].
*
* @param grouping [Grouping] to derive the member data from.
- * @param musicSettings [MusicSettings] to for user parsing configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
-class ArtistImpl(grouping: Grouping, musicSettings: MusicSettings) : Artist {
+class ArtistImpl(
+ grouping: Grouping,
+ private val nameFactory: Name.Known.Factory
+) : Artist {
private val rawArtist = grouping.raw.inner
override val uid =
@@ -373,7 +404,7 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
override val name =
- rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
+ rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
?: Name.Unknown(R.string.def_artist)
override val songs: Set
@@ -414,6 +445,7 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
hashCode = 31 * hashCode + rawArtist.hashCode()
+ hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
@@ -421,10 +453,13 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
+ // Since equality on public-facing music models is not identical to the tag equality,
+ // we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
+ nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
@@ -459,15 +494,18 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti
* Library-backed implementation of [Genre].
*
* @param grouping [Grouping] to derive the member data from.
- * @param musicSettings [MusicSettings] to for user parsing configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
-class GenreImpl(grouping: Grouping, musicSettings: MusicSettings) : Genre {
+class GenreImpl(
+ grouping: Grouping,
+ private val nameFactory: Name.Known.Factory
+) : Genre {
private val rawGenre = grouping.raw.inner
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
override val name =
- rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
+ rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
?: Name.Unknown(R.string.def_genre)
override val songs: Set
@@ -491,13 +529,18 @@ class GenreImpl(grouping: Grouping, musicSettings: MusicSett
durationMs = totalDuration
hashCode = 31 * hashCode + rawGenre.hashCode()
+ hashCode = 31 * nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
- other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
+ other is GenreImpl &&
+ uid == other.uid &&
+ rawGenre == other.rawGenre &&
+ nameFactory == other.nameFactory &&
+ songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
index bbde5aca3..09f4d8035 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt
@@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.info
import android.content.Context
import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
import java.text.CollationKey
import java.text.Collator
import org.oxycblt.auxio.music.MusicSettings
@@ -54,36 +55,7 @@ sealed interface Name : Comparable {
abstract val sort: String?
/** A tokenized version of the name that will be compared. */
- protected abstract val sortTokens: List
-
- /** An individual part of a name string that can be compared intelligently. */
- protected data class SortToken(val collationKey: CollationKey, val type: Type) :
- Comparable {
- override fun compareTo(other: SortToken): Int {
- // Numeric tokens should always be lower than lexicographic tokens.
- val modeComp = type.compareTo(other.type)
- if (modeComp != 0) {
- return modeComp
- }
-
- // Numeric strings must be ordered by magnitude, thus immediately short-circuit
- // the comparison if the lengths do not match.
- if (type == Type.NUMERIC &&
- collationKey.sourceString.length != other.collationKey.sourceString.length) {
- return collationKey.sourceString.length - other.collationKey.sourceString.length
- }
-
- return collationKey.compareTo(other.collationKey)
- }
-
- /** Denotes the type of comparison to be performed with this token. */
- enum class Type {
- /** Compare as a digit string, like "65". */
- NUMERIC,
- /** Compare as a standard alphanumeric string, like "65daysofstatic" */
- LEXICOGRAPHIC
- }
- }
+ @VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List
final override val thumb: String
get() =
@@ -108,20 +80,30 @@ sealed interface Name : Comparable {
is Unknown -> 1
}
- companion object {
+ interface Factory {
/**
* Create a new instance of [Name.Known]
*
* @param raw The raw name obtained from the music item
* @param sort The raw sort name obtained from the music item
- * @param musicSettings [MusicSettings] required for name configuration.
*/
- fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known =
- if (musicSettings.intelligentSorting) {
- IntelligentKnownName(raw, sort)
- } else {
- SimpleKnownName(raw, sort)
- }
+ fun parse(raw: String, sort: String?): Known
+
+ companion object {
+ /**
+ * Creates a new instance from the **current state** of the given [MusicSettings]'s
+ * user-defined name configuration.
+ *
+ * @param settings The [MusicSettings] to use.
+ * @return A [Factory] instance reflecting the configuration state.
+ */
+ fun from(settings: MusicSettings) =
+ if (settings.intelligentSorting) {
+ IntelligentKnownName.Factory
+ } else {
+ SimpleKnownName.Factory
+ }
+ }
}
}
@@ -148,22 +130,28 @@ sealed interface Name : Comparable {
private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private val punctRegex by lazy { Regex("[\\p{Punct}+]") }
+// TODO: Consider how you want to handle whitespace and "gaps" in names.
+
/**
* Plain [Name.Known] implementation that is internationalization-safe.
*
* @author Alexander Capehart (OxygenCobalt)
*/
-private data class SimpleKnownName(override val raw: String, override val sort: String?) :
- Name.Known() {
+@VisibleForTesting
+data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() {
override val sortTokens = listOf(parseToken(sort ?: raw))
private fun parseToken(name: String): SortToken {
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
- val stripped = name.replace(punctRegex, "").ifEmpty { name }
+ val stripped = name.replace(punctRegex, "").trim().ifEmpty { name }
val collationKey = collator.getCollationKey(stripped)
// Always use lexicographic mode since we aren't parsing any numeric components
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
}
+
+ data object Factory : Name.Known.Factory {
+ override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort)
+ }
}
/**
@@ -171,7 +159,8 @@ private data class SimpleKnownName(override val raw: String, override val sort:
*
* @author Alexander Capehart (OxygenCobalt)
*/
-private data class IntelligentKnownName(override val raw: String, override val sort: String?) :
+@VisibleForTesting
+data class IntelligentKnownName(override val raw: String, override val sort: String?) :
Name.Known() {
override val sortTokens = parseTokens(sort ?: raw)
@@ -180,7 +169,8 @@ private data class IntelligentKnownName(override val raw: String, override val s
// optimize it
val stripped =
name
- // Remove excess punctuation from the string, as those u
+ // Remove excess punctuation from the string, as those usually aren't
+ // considered in sorting.
.replace(punctRegex, "")
.ifEmpty { name }
.run {
@@ -218,7 +208,40 @@ private data class IntelligentKnownName(override val raw: String, override val s
}
}
+ data object Factory : Name.Known.Factory {
+ override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
+ }
+
companion object {
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
}
}
+
+/** An individual part of a name string that can be compared intelligently. */
+@VisibleForTesting(VisibleForTesting.PROTECTED)
+data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable {
+ override fun compareTo(other: SortToken): Int {
+ // Numeric tokens should always be lower than lexicographic tokens.
+ val modeComp = type.compareTo(other.type)
+ if (modeComp != 0) {
+ return modeComp
+ }
+
+ // Numeric strings must be ordered by magnitude, thus immediately short-circuit
+ // the comparison if the lengths do not match.
+ if (type == Type.NUMERIC &&
+ collationKey.sourceString.length != other.collationKey.sourceString.length) {
+ return collationKey.sourceString.length - other.collationKey.sourceString.length
+ }
+
+ return collationKey.compareTo(other.collationKey)
+ }
+
+ /** Denotes the type of comparison to be performed with this token. */
+ enum class Type {
+ /** Compare as a digit string, like "65". */
+ NUMERIC,
+ /** Compare as a standard alphanumeric string, like "65daysofstatic" */
+ LEXICOGRAPHIC
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt
new file mode 100644
index 000000000..8d2740e74
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * Separators.kt is part of Auxio.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.metadata
+
+import androidx.annotation.VisibleForTesting
+import org.oxycblt.auxio.music.MusicSettings
+
+/**
+ * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags
+ * that may be delimited with a separator character.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+interface Separators {
+ /**
+ * Parse a separated value from one or more strings. If the value is already composed of more
+ * than one value, nothing is done. Otherwise, it will attempt to split it based on the user's
+ * separator preferences.
+ *
+ * @return A new list of one or more [String]s parsed by the separator configuration
+ */
+ fun split(strings: List): List
+
+ companion object {
+ const val COMMA = ','
+ const val SEMICOLON = ';'
+ const val SLASH = '/'
+ const val PLUS = '+'
+ const val AND = '&'
+
+ /**
+ * Creates a new instance from the **current state** of the given [MusicSettings]'s
+ * user-defined separator configuration.
+ *
+ * @param settings The [MusicSettings] to use.
+ * @return A new [Separators] instance reflecting the configuration state.
+ */
+ fun from(settings: MusicSettings) = from(settings.separators)
+
+ @VisibleForTesting
+ fun from(chars: String) =
+ if (chars.isNotEmpty()) {
+ CharSeparators(chars.toSet())
+ } else {
+ NoSeparators
+ }
+ }
+}
+
+private data class CharSeparators(private val chars: Set) : Separators {
+ override fun split(strings: List) =
+ if (strings.size == 1) splitImpl(strings.first()) else strings
+
+ private fun splitImpl(string: String) =
+ string.splitEscaped { chars.contains(it) }.correctWhitespace()
+}
+
+private object NoSeparators : Separators {
+ override fun split(strings: List) = strings
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt
index d74b9ba53..31195c408 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt
@@ -52,7 +52,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment
- musicSettings.multiValueSeparators = getCurrentSeparators()
+ musicSettings.separators = getCurrentSeparators()
}
}
@@ -68,8 +68,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment binding.separatorComma.isChecked = true
@@ -102,14 +101,6 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment.parseMultiValue(settings: MusicSettings) =
- if (size == 1) {
- first().maybeParseBySeparators(settings)
- } else {
- // Nothing to do.
- this
- }
-
// TODO: Remove the escaping checks, it's too expensive to do this for every single tag.
+// TODO: I want to eventually be able to move a lot of this into TagWorker once I no longer have
+// to deal with the cross-module dependencies of MediaStoreExtractor.
+
/**
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
* the selector.
@@ -101,17 +87,6 @@ fun String.correctWhitespace() = trim().ifBlank { null }
*/
fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() }
-/**
- * Attempt to parse a string by the user's separator preferences.
- *
- * @param settings [MusicSettings] required to obtain user separator configuration.
- * @return A list of one or more [String]s that were split up by the user-defined separators.
- */
-private fun String.maybeParseBySeparators(settings: MusicSettings): List {
- if (settings.multiValueSeparators.isEmpty()) return listOf(this)
- return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
-}
-
/// --- ID3v2 PARSING ---
/**
@@ -165,12 +140,12 @@ fun transformPositionField(pos: Int?, total: Int?) =
* representations of genre fields into their named counterparts, and split up singular ID3v2-style
* integer genre fields into one or more genres.
*
- * @param settings [MusicSettings] required to obtain user separator configuration.
- * @return A list of one or more genre names..
+ * @return A list of one or more genre names, or null if this multi-value list has no valid
+ * formatting.
*/
-fun List.parseId3GenreNames(settings: MusicSettings) =
+fun List.parseId3GenreNames() =
if (size == 1) {
- first().parseId3MultiValueGenre(settings)
+ first().parseId3MultiValueGenre()
} else {
// Nothing to split, just map any ID3v1 genres to their name counterparts.
map { it.parseId3v1Genre() ?: it }
@@ -179,11 +154,10 @@ fun List.parseId3GenreNames(settings: MusicSettings) =
/**
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
*
- * @param settings [MusicSettings] required to obtain user separator configuration.
- * @return A list of one or more genre names.
+ * @return list of one or more genre names, or null if this is not in ID3v2 format.
*/
-private fun String.parseId3MultiValueGenre(settings: MusicSettings) =
- parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
+private fun String.parseId3MultiValueGenre() =
+ parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre()
/**
* Parse an ID3v1 integer genre field.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
index fae02585e..196c7c0dc 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt
@@ -77,7 +77,6 @@ private class TagWorkerImpl(
private val rawSong: RawSong,
private val future: Future
) : TagWorker {
-
override fun poll(): RawSong? {
if (!future.isDone) {
// Not done yet, nothing to do.
diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
index ffe7a5174..fe4418894 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt
@@ -19,7 +19,6 @@
package org.oxycblt.auxio.music.user
import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
@@ -51,10 +50,10 @@ private constructor(
* Clone the data in this instance to a new [PlaylistImpl] with the given [name].
*
* @param name The new name to use.
- * @param musicSettings [MusicSettings] required for name configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
*/
- fun edit(name: String, musicSettings: MusicSettings) =
- PlaylistImpl(uid, Name.Known.from(name, null, musicSettings), songs)
+ fun edit(name: String, nameFactory: Name.Known.Factory) =
+ PlaylistImpl(uid, nameFactory.parse(name, null), songs)
/**
* Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s.
@@ -76,29 +75,26 @@ private constructor(
*
* @param name The name of the playlist.
* @param songs The songs to initially populate the playlist with.
- * @param musicSettings [MusicSettings] required for name configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
*/
- fun from(name: String, songs: List, musicSettings: MusicSettings) =
- PlaylistImpl(
- Music.UID.auxio(MusicType.PLAYLISTS),
- Name.Known.from(name, null, musicSettings),
- songs)
+ fun from(name: String, songs: List, nameFactory: Name.Known.Factory) =
+ PlaylistImpl(Music.UID.auxio(MusicType.PLAYLISTS), nameFactory.parse(name, null), songs)
/**
* Populate a new instance from a read [RawPlaylist].
*
* @param rawPlaylist The [RawPlaylist] to read from.
* @param deviceLibrary The [DeviceLibrary] to initialize from.
- * @param musicSettings [MusicSettings] required for name configuration.
+ * @param nameFactory The [Name.Known.Factory] to interpret name information with.
*/
fun fromRaw(
rawPlaylist: RawPlaylist,
deviceLibrary: DeviceLibrary,
- musicSettings: MusicSettings
+ nameFactory: Name.Known.Factory
) =
PlaylistImpl(
rawPlaylist.playlistInfo.playlistUid,
- Name.Known.from(rawPlaylist.playlistInfo.name, null, musicSettings),
+ nameFactory.parse(rawPlaylist.playlistInfo.name, null),
rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) })
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt
index 06de6d64f..faae9594b 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt
@@ -26,6 +26,7 @@ import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
+import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
@@ -144,7 +145,9 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
UserLibrary.Factory {
override suspend fun query() =
try {
- playlistDao.readRawPlaylists()
+ val rawPlaylists = playlistDao.readRawPlaylists()
+ logD("Successfully read ${rawPlaylists.size} playlists")
+ rawPlaylists
} catch (e: Exception) {
logE("Unable to read playlists: $e")
listOf()
@@ -154,11 +157,10 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus
rawPlaylists: List,
deviceLibrary: DeviceLibrary
): MutableUserLibrary {
- logD("Successfully read ${rawPlaylists.size} playlists")
- // Convert the database playlist information to actual usable playlists.
+ val nameFactory = Name.Known.Factory.from(musicSettings)
val playlistMap = mutableMapOf()
for (rawPlaylist in rawPlaylists) {
- val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings)
+ val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory)
playlistMap[playlistImpl.uid] = playlistImpl
}
return UserLibraryImpl(playlistDao, playlistMap, musicSettings)
@@ -184,7 +186,7 @@ private class UserLibraryImpl(
override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name }
override suspend fun createPlaylist(name: String, songs: List): Playlist? {
- val playlistImpl = PlaylistImpl.from(name, songs, musicSettings)
+ val playlistImpl = PlaylistImpl.from(name, songs, Name.Known.Factory.from(musicSettings))
synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl }
val rawPlaylist =
RawPlaylist(
@@ -207,7 +209,9 @@ private class UserLibraryImpl(
val playlistImpl =
synchronized(this) {
requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" }
- .also { playlistMap[it.uid] = it.edit(name, musicSettings) }
+ .also {
+ playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings))
+ }
}
return try {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
index 77736a3f3..c0b901c3b 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt
@@ -38,7 +38,6 @@ import kotlin.math.abs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.detail.DetailViewModel
-import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
@@ -48,7 +47,6 @@ import org.oxycblt.auxio.playback.queue.QueueViewModel
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.ViewBindingFragment
-import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
@@ -107,7 +105,7 @@ class PlaybackPanelFragment :
playbackModel.song.value?.let {
// No playback options are actually available in the menu, so use a junk
// PlaySong option.
- listModel.openMenu(R.menu.item_playback_song, it, PlaySong.ByItself)
+ listModel.openMenu(R.menu.playback_song, it, PlaySong.ByItself)
}
}
}
@@ -120,6 +118,20 @@ class PlaybackPanelFragment :
val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView
recycler.isNestedScrollingEnabled = false
}
+ // Set up marquee on song information, alongside click handlers that navigate to each
+ // respective item.
+ binding.playbackSong.apply {
+ isSelected = true
+ setOnClickListener { navigateToCurrentSong() }
+ }
+ binding.playbackArtist.apply {
+ isSelected = true
+ setOnClickListener { navigateToCurrentArtist() }
+ }
+ binding.playbackAlbum.apply {
+ isSelected = true
+ setOnClickListener { navigateToCurrentAlbum() }
+ }
binding.playbackSeekBar.listener = this
@@ -140,7 +152,6 @@ class PlaybackPanelFragment :
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
collectImmediately(queueModel.queue, ::updateQueue)
collectImmediately(queueModel.index, ::updateQueuePosition)
- collect(detailModel.toShow.flow, ::handleShow)
}
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
@@ -226,25 +237,8 @@ class PlaybackPanelFragment :
requireBinding().playbackShuffle.isActivated = isShuffled
}
- private fun handleShow(show: Show?) {
- when (show) {
- is Show.SongAlbumDetails,
- is Show.ArtistDetails,
- is Show.AlbumDetails -> playbackModel.openMain()
- is Show.SongDetails,
- is Show.SongArtistDecision,
- is Show.AlbumArtistDecision,
- is Show.GenreDetails,
- is Show.PlaylistDetails,
- null -> {}
- }
- }
-
override fun navigateToCurrentSong() {
- playbackModel.song.value?.let {
- detailModel.showAlbum(it)
- playbackModel.openMain()
- }
+ playbackModel.song.value?.let(detailModel::showAlbum)
}
override fun navigateToCurrentArtist() {
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
index 8e6ff346f..efb266eab 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt
@@ -338,8 +338,7 @@ constructor(
song,
object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) {
- this@MediaSessionComponent.logD(
- "Bitmap loaded, applying media session and posting notification")
+ logD("Bitmap loaded, applying media session and posting notification")
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
val metadata = builder.build()
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
index 128a3c394..da74d66a2 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt
@@ -119,7 +119,7 @@ class SearchFragment : ListFragment() {
if (!launchedKeyboard) {
// Auto-open the keyboard when this view is shown
- this@SearchFragment.logD("Keyboard is not shown yet")
+ logD("Keyboard is not shown yet")
showKeyboard(this)
launchedKeyboard = true
}
@@ -184,11 +184,11 @@ class SearchFragment : ListFragment() {
override fun onOpenMenu(item: Music) {
when (item) {
- is Song -> listModel.openMenu(R.menu.item_song, item, searchModel.playWith)
- is Album -> listModel.openMenu(R.menu.item_album, item)
- is Artist -> listModel.openMenu(R.menu.item_parent, item)
- is Genre -> listModel.openMenu(R.menu.item_parent, item)
- is Playlist -> listModel.openMenu(R.menu.item_playlist, item)
+ is Song -> listModel.openMenu(R.menu.song, item, searchModel.playWith)
+ is Album -> listModel.openMenu(R.menu.album, item)
+ is Artist -> listModel.openMenu(R.menu.parent, item)
+ is Genre -> listModel.openMenu(R.menu.parent, item)
+ is Playlist -> listModel.openMenu(R.menu.playlist, item)
}
}
@@ -261,6 +261,7 @@ class SearchFragment : ListFragment() {
is Menu.ForArtist -> SearchFragmentDirections.openArtistMenu(menu.parcel)
is Menu.ForGenre -> SearchFragmentDirections.openGenreMenu(menu.parcel)
is Menu.ForPlaylist -> SearchFragmentDirections.openPlaylistMenu(menu.parcel)
+ is Menu.ForSelection -> SearchFragmentDirections.openSelectionMenu(menu.parcel)
}
findNavController().navigateSafe(directions)
// Keyboard is no longer needed.
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
index cd6217a9f..3c3258ab9 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt
@@ -18,13 +18,8 @@
package org.oxycblt.auxio.settings
-import android.content.ActivityNotFoundException
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
-import androidx.core.net.toUri
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
@@ -37,8 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collectImmediately
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.showToast
+import org.oxycblt.auxio.util.openInBrowser
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
@@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment() {
}
binding.aboutVersion.text = BuildConfig.VERSION_NAME
- binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) }
- binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) }
- binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
- binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) }
+ binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) }
+ binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) }
+ binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) }
+ binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) }
// VIEWMODEL SETUP
collectImmediately(musicModel.statistics, ::updateStatistics)
@@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment() {
(statistics?.durationMs ?: 0).formatDurationMs(false))
}
- /**
- * Open the given URI in a web browser.
- *
- * @param uri The URL to open.
- */
- private fun openLinkInBrowser(uri: String) {
- logD("Opening $uri")
- val context = requireContext()
- val browserIntent =
- Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- // Android 11 seems to now handle the app chooser situations on its own now
- // [along with adding a new permission that breaks the old manual code], so
- // we just do a typical activity launch.
- logD("Using API 30+ chooser")
- try {
- context.startActivity(browserIntent)
- } catch (e: ActivityNotFoundException) {
- // No app installed to open the link
- context.showToast(R.string.err_no_app)
- }
- } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- // On older versions of android, opening links from an ACTION_VIEW intent might
- // not work in all cases, especially when no default app was set. If that is the
- // case, we will try to manually handle these cases before we try to launch the
- // browser.
- logD("Resolving browser activity for chooser")
- val pkgName =
- context.packageManager
- .resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
- ?.run { activityInfo.packageName }
-
- if (pkgName != null) {
- if (pkgName == "android") {
- // No default browser [Must open app chooser, may not be supported]
- logD("No default browser found")
- openAppChooser(browserIntent)
- } else logD("Opening browser intent")
- try {
- browserIntent.setPackage(pkgName)
- startActivity(browserIntent)
- } catch (e: ActivityNotFoundException) {
- // Not a browser but an app chooser
- browserIntent.setPackage(null)
- openAppChooser(browserIntent)
- }
- } else {
- // No app installed to open the link
- context.showToast(R.string.err_no_app)
- }
- }
- }
-
- /**
- * Open an app chooser for a given [Intent].
- *
- * @param intent The [Intent] to show an app chooser for.
- */
- private fun openAppChooser(intent: Intent) {
- logD("Opening app chooser for ${intent.action}")
- val chooserIntent =
- Intent(Intent.ACTION_CHOOSER)
- .putExtra(Intent.EXTRA_INTENT, intent)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- startActivity(chooserIntent)
- }
-
private companion object {
/** The URL to the source code. */
const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio"
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt
new file mode 100644
index 000000000..58fe95c14
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * DialogAwareNavigationListener.kt is part of Auxio.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.ui
+
+import android.os.Bundle
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+
+/**
+ * A [NavController.OnDestinationChangedListener] that will call [callback] when moving between
+ * fragments only (not between dialogs or anything similar).
+ *
+ * Note: This only works because of special naming used in Auxio's navigation graphs. Keep this in
+ * mind when porting to other projects.
+ *
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class DialogAwareNavigationListener(private val callback: () -> Unit) :
+ NavController.OnDestinationChangedListener {
+ private var currentDestination: NavDestination? = null
+
+ /**
+ * Attach this instance to a [NavController]. This should be done in the onStart method of a
+ * Fragment.
+ *
+ * @param navController The [NavController] to add to.
+ */
+ fun attach(navController: NavController) {
+ currentDestination = null
+ navController.addOnDestinationChangedListener(this)
+ }
+
+ /**
+ * Remove this listener from it's [NavController]. This should be done in the onStop method of a
+ * Fragment.
+ *
+ * @param navController The [NavController] to remove from. Should be the same on used in
+ * [attach].
+ */
+ fun release(navController: NavController) {
+ currentDestination = null
+ navController.removeOnDestinationChangedListener(this)
+ }
+
+ override fun onDestinationChanged(
+ controller: NavController,
+ destination: NavDestination,
+ arguments: Bundle?
+ ) {
+ // Drop the initial call by NavController that simply provides us with the current
+ // destination. This would cause the selection state to be lost every time the device
+ // rotates.
+ val lastDestination = currentDestination
+ currentDestination = destination
+ if (lastDestination == null) {
+ return
+ }
+
+ if (!lastDestination.isDialog() && !destination.isDialog()) {
+ callback()
+ }
+ }
+
+ private fun NavDestination.isDialog() = label?.endsWith("dialog") == true
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt
index 3a5adce95..8abffb38f 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt
@@ -26,9 +26,11 @@ import android.view.ViewGroup
import androidx.annotation.StyleRes
import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
+import com.google.android.material.bottomsheet.BackportBottomSheetDialog
+import com.google.android.material.bottomsheet.BackportBottomSheetDialogFragment
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@@ -39,10 +41,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ViewBindingBottomSheetDialogFragment :
- BottomSheetDialogFragment() {
+ BackportBottomSheetDialogFragment() {
private var _binding: VB? = null
- override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheetDialog =
+ override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog =
TweakedBottomSheetDialog(requireContext(), theme)
/**
@@ -109,19 +111,29 @@ abstract class ViewBindingBottomSheetDialogFragment :
private inner class TweakedBottomSheetDialog
@JvmOverloads
- constructor(context: Context, @StyleRes theme: Int = 0) : BottomSheetDialog(context, theme) {
+ constructor(context: Context, @StyleRes theme: Int = 0) :
+ BackportBottomSheetDialog(context, theme) {
+ private var avoidUnusableCollapsedState = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- // Collapsed state is bugged in phone landscape mode and shows only 10% of the dialog.
- // Just disable it and go directly from expanded -> hidden.
- behavior.skipCollapsed = true
+ // Automatic peek height calculations are bugged in phone landscape mode and show only
+ // 10% of the dialog. Just disable it in that case and go directly from expanded ->
+ // hidden.
+ val metrics = context.resources.displayMetrics
+ avoidUnusableCollapsedState =
+ metrics.heightPixels - metrics.widthPixels <
+ context.getDimenPixels(
+ com.google.android.material.R.dimen.design_bottom_sheet_peek_height_min)
+ behavior.skipCollapsed = avoidUnusableCollapsedState
}
override fun onStart() {
super.onStart()
- // Manually trigger an expanded transition to make window insets actually apply to
- // the dialog on the first layout pass. I don't know why this works.
- behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ if (avoidUnusableCollapsedState) {
+ // skipCollapsed isn't enough, also need to immediately snap to expanded state.
+ behavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
+ }
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
index e3f50ccec..1662c47c5 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
@@ -18,7 +18,10 @@
package org.oxycblt.auxio.util
+import android.content.ActivityNotFoundException
import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
import android.graphics.PointF
import android.graphics.drawable.Drawable
import android.os.Build
@@ -28,10 +31,12 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.view.menu.ActionMenuItemView
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.AppCompatButton
+import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ShareCompat
import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat
+import androidx.core.net.toUri
import androidx.core.view.children
import androidx.navigation.NavController
import androidx.navigation.NavDirections
@@ -40,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.MaterialToolbar
import java.lang.IllegalArgumentException
+import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
@@ -111,7 +117,7 @@ val ViewBinding.context: Context
* Override the behavior of a [MaterialToolbar]'s overflow menu to do something else. This is
* extremely dumb, but required to hook overflow menus to bottom sheet menus.
*/
-fun MaterialToolbar.overrideOnOverflowMenuClick(block: (View) -> Unit) {
+fun Toolbar.overrideOnOverflowMenuClick(block: (View) -> Unit) {
for (toolbarChild in children) {
if (toolbarChild is ActionMenuView) {
for (menuChild in toolbarChild.children) {
@@ -321,3 +327,65 @@ fun Context.share(songs: Collection) {
builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser()
}
+
+/**
+ * Open the given URI in a web browser.
+ *
+ * @param uri The URL to open.
+ */
+fun Context.openInBrowser(uri: String) {
+ fun openAppChooser(intent: Intent) {
+ logD("Opening app chooser for ${intent.action}")
+ val chooserIntent =
+ Intent(Intent.ACTION_CHOOSER)
+ .putExtra(Intent.EXTRA_INTENT, intent)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(chooserIntent)
+ }
+
+ logD("Opening $uri")
+ val browserIntent =
+ Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ // Android 11 seems to now handle the app chooser situations on its own now
+ // [along with adding a new permission that breaks the old manual code], so
+ // we just do a typical activity launch.
+ logD("Using API 30+ chooser")
+ try {
+ startActivity(browserIntent)
+ } catch (e: ActivityNotFoundException) {
+ // No app installed to open the link
+ showToast(R.string.err_no_app)
+ }
+ } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ // On older versions of android, opening links from an ACTION_VIEW intent might
+ // not work in all cases, especially when no default app was set. If that is the
+ // case, we will try to manually handle these cases before we try to launch the
+ // browser.
+ logD("Resolving browser activity for chooser")
+ val pkgName =
+ packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)?.run {
+ activityInfo.packageName
+ }
+
+ if (pkgName != null) {
+ if (pkgName == "android") {
+ // No default browser [Must open app chooser, may not be supported]
+ logD("No default browser found")
+ openAppChooser(browserIntent)
+ } else logD("Opening browser intent")
+ try {
+ browserIntent.setPackage(pkgName)
+ startActivity(browserIntent)
+ } catch (e: ActivityNotFoundException) {
+ // Not a browser but an app chooser
+ browserIntent.setPackage(null)
+ openAppChooser(browserIntent)
+ }
+ } else {
+ // No app installed to open the link
+ showToast(R.string.err_no_app)
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
index d1b0125eb..8b8d8c6a1 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt
@@ -82,8 +82,10 @@ fun lazyReflectedField(clazz: KClass<*>, field: String) = lazy {
* @param clazz The [KClass] to reflect into.
* @param method The name of the method to obtain.
*/
-fun lazyReflectedMethod(clazz: KClass<*>, method: String) = lazy {
- clazz.java.getDeclaredMethod(method).also { it.isAccessible = true }
+fun lazyReflectedMethod(clazz: KClass<*>, method: String, vararg params: KClass<*>) = lazy {
+ clazz.java.getDeclaredMethod(method, *params.map { it.java }.toTypedArray()).also {
+ it.isAccessible = true
+ }
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
index f7418a61e..4b1f800b4 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt
@@ -18,27 +18,24 @@
package org.oxycblt.auxio.util
-import android.util.Log
import org.oxycblt.auxio.BuildConfig
-
-// Shortcut functions for logging.
-// Yes, I know timber exists but this does what I need.
+import timber.log.Timber
/**
* Log an object to the debug channel. Automatically handles tags.
*
* @param obj The object to log.
*/
-fun Any.logD(obj: Any?) = logD("$obj")
+fun logD(obj: Any?) = logD("$obj")
/**
* Log a string message to the debug channel. Automatically handles tags.
*
* @param msg The message to log.
*/
-fun Any.logD(msg: String) {
+fun logD(msg: String) {
if (BuildConfig.DEBUG && !copyleftNotice()) {
- Log.d(autoTag, msg)
+ Timber.d(msg)
}
}
@@ -47,21 +44,14 @@ fun Any.logD(msg: String) {
*
* @param msg The message to log.
*/
-fun Any.logW(msg: String) = Log.w(autoTag, msg)
+fun logW(msg: String) = Timber.w(msg)
/**
* Log a string message to the error channel. Automatically handles tags.
*
* @param msg The message to log.
*/
-fun Any.logE(msg: String) = Log.e(autoTag, msg)
-
-/**
- * The LogCat-suitable tag for this string. Consists of the object's name, or "Anonymous Object" if
- * the object does not exist.
- */
-private val Any.autoTag: String
- get() = "Auxio.${this::class.simpleName ?: "Anonymous Object"}"
+fun logE(msg: String) = Timber.e(msg)
/**
* Please don't plagiarize Auxio! You are free to remove this as long as you continue to keep your
@@ -71,7 +61,7 @@ private val Any.autoTag: String
private fun copyleftNotice(): Boolean {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
- Log.d(
+ Timber.d(
"Auxio Project",
"Friendly reminder: Auxio is licensed under the " +
"GPLv3 and all derivative apps must be made open source!")
diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml
new file mode 100644
index 000000000..65bb96df5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_copy_24.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
index adeac9d27..b7ead10f6 100644
--- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
+++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml
@@ -29,8 +29,8 @@
android:id="@+id/playback_seek_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/spacing_small"
- android:layout_marginEnd="@dimen/spacing_small"
+ android:layout_marginStart="@dimen/spacing_tiny"
+ android:layout_marginEnd="@dimen/spacing_tiny"
app:layout_constraintBottom_toTopOf="@+id/playback_controls_container"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
diff --git a/app/src/main/res/layout/design_bottom_sheet_dialog.xml b/app/src/main/res/layout/design_bottom_sheet_dialog.xml
new file mode 100644
index 000000000..bb70eccbb
--- /dev/null
+++ b/app/src/main/res/layout/design_bottom_sheet_dialog.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_error_details.xml b/app/src/main/res/layout/dialog_error_details.xml
new file mode 100644
index 000000000..729c17d0b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_error_details.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index 049256481..f1b5c8c80 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -70,8 +70,8 @@
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="@dimen/spacing_medium"
- android:fitsSystemWindows="true"
- android:visibility="invisible">
+ android:visibility="invisible"
+ android:fitsSystemWindows="true">
@@ -103,20 +103,40 @@
android:layout_marginEnd="@dimen/spacing_medium"
android:indeterminate="true"
app:indeterminateAnimationType="disjoint"
- app:layout_constraintBottom_toBottomOf="@+id/home_indexing_action"
- app:layout_constraintTop_toTopOf="@+id/home_indexing_action" />
+ app:layout_constraintBottom_toBottomOf="@+id/home_indexing_actions"
+ app:layout_constraintTop_toTopOf="@+id/home_indexing_actions" />
-
+ tools:layout_editor_absoluteX="16dp">
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index 58259f50b..98328654d 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -56,6 +56,7 @@
android:id="@+id/queue_handle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:paddingBottom="@dimen/spacing_medium"
app:layout_constraintTop_toTopOf="parent" />
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/item_song.xml b/app/src/main/res/menu/song.xml
similarity index 100%
rename from app/src/main/res/menu/item_song.xml
rename to app/src/main/res/menu/song.xml
diff --git a/app/src/main/res/menu/toolbar_selection.xml b/app/src/main/res/menu/toolbar_selection.xml
index 9dfda8a30..e1cf43ef0 100644
--- a/app/src/main/res/menu/toolbar_selection.xml
+++ b/app/src/main/res/menu/toolbar_selection.xml
@@ -12,19 +12,7 @@
android:icon="@drawable/ic_playlist_add_24"
app:showAsAction="ifRoom"/>
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml
index 979b09b2d..a974b3360 100644
--- a/app/src/main/res/navigation/inner.xml
+++ b/app/src/main/res/navigation/inner.xml
@@ -7,7 +7,7 @@
+
@@ -78,8 +81,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/outer.xml b/app/src/main/res/navigation/outer.xml
index 23491b998..b8198339e 100644
--- a/app/src/main/res/navigation/outer.xml
+++ b/app/src/main/res/navigation/outer.xml
@@ -8,7 +8,7 @@
+ android:label="settings_fragment">
@@ -41,7 +41,7 @@
+ android:label="ui_preferences_fragment">
@@ -50,7 +50,7 @@
+ android:label="personalize_preferences_fragment">
@@ -59,7 +59,7 @@
+ android:label="personalize_preferences_fragment">
@@ -68,7 +68,7 @@
+ android:label="personalize_preferences_fragment">
diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml
index 9dc9abb35..97794174f 100644
--- a/app/src/main/res/values-ar-rIQ/strings.xml
+++ b/app/src/main/res/values-ar-rIQ/strings.xml
@@ -145,8 +145,6 @@
الحجم
المسار
إحصائيات المكتبة
- تشغي الاغاني المحددة بترتيب عشوائي
- تشغيل الموسيقى المحددة
معدل البت
اسم الملف
تجميع مباشر
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index fcbf1ee96..ca6f6fafd 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -14,7 +14,6 @@
حذف قائمة التشغيل؟
بحث
تصفية
- تشغيل المختارة
تشغيل التالي
إضافة للطابور
إضافة لقائمة التشغيل
@@ -28,7 +27,6 @@
قائمة تشغيل جديدة
إعادة تسمية قائمة التشغيل
تعديل
- خلط المختارة
طابور
خلط
اذهب للفنان
diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml
index 1b783cd27..106d70d59 100644
--- a/app/src/main/res/values-be/strings.xml
+++ b/app/src/main/res/values-be/strings.xml
@@ -72,7 +72,6 @@
Зараз іграе
Гуляць
Ператасаваць
- Выбрана перамешванне
Памер
Ператасаваць
Адмяніць
@@ -81,7 +80,6 @@
Гуляць далей
Дадаць у чаргу
Эквалайзер
- Гуляць выбрана
Чарга
Перайсці да альбома
Перайсці да выканаўцы
@@ -298,4 +296,8 @@
Песня
Прайграць песню самастойна
Выгляд
+ Сартаваць па
+ Напрамак
+ Абярыце малюнак
+ Абярыце
\ No newline at end of file
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index d53bff062..9e55b50e3 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -260,9 +260,7 @@
Nepodařilo se vymazat stav
Znovu najít hudbu
Vymazat mezipaměť značek a znovu úplně znovu načíst hudební knihovnu (pomalejší, ale úplnější)
- Přehrát vybrané
Vybráno %d
- Náhodně přehrát vybrané
Přehrát z žánru
Wiki
%1$s, %2$s
@@ -309,4 +307,8 @@
Skladba
Zobrazit
Přehrát skladbu samostatně
+ Směr
+ Seřadit podle
+ Výběr obrázku
+ Výběr
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 09d153413..d30ef1144 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -251,8 +251,6 @@
Zustand konnte nicht gespeichert werden
Music neu scannen
Tag-Cache leeren und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger)
- Ausgewählte abspielen
- Ausgewählte zufällig abspielen
%d ausgewählt
Vom Genre abspielen
Wiki
@@ -299,4 +297,7 @@
Alle Album-Cover auf ein Seitenverhältnis von 1:1 zuschneiden
Lied
Ansehen
+ Lied selbst spielen
+ Richtung
+ Sortieren nach
\ No newline at end of file
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 0e7da1415..b5730db8e 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -135,8 +135,6 @@
Σύνθεση ζωντανών κομματιών
Σύνθεση ρεμίξ
Ισοσταθμιστής
- Αναπαραγωγή επιλεγμένου
- Τυχαία αναπαραγωγή επιλεγμένων
Ενιαία κυκλοφορία
Σινγκλ
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index c2e376431..0712780dd 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -255,9 +255,7 @@
No se puede borrar el estado
Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo)
Volver a escanear la música
- Nodo aleatorio seleccionado
%d seleccionado
- Reproducir los seleccionados
Reproducir desde el género
Wiki
%1$s, %2$s
@@ -304,4 +302,8 @@
Canción
Vista
Reproducir la canción por tí mismo
+ Ordenar por
+ Dirección
+ Selección de imágenes
+ Selección
\ No newline at end of file
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 1f222e0a7..55677cbf1 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -37,7 +37,6 @@
Nyt toistetaan
Taajuuskorjain
Toista
- Toisto valittu
Sekoita
Jono
Lisää jonoon
@@ -219,7 +218,6 @@
ReplayGain
Suosi albumia
ReplayGain-strategia
- Sekoitus valittu
Automaattinen uudelleenlataus
Automaattitoisto kuulokkeilla
Aloita aina toisto, kun kuulokkeet yhdistetään (ei välttämättä toimi kaikilla laitteilla)
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 822484268..b063a1730 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -134,8 +134,6 @@
Genre inconnu
Dynamique
Cyan
- Lecture aléatoire sélectionnée
- Réinitialiser
Aucun dossier
Supprimer le dossier
Artiste inconnu
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index 5c3288e6a..faf684102 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -49,7 +49,6 @@
Reproducir
Mezcla
Reproducir seguinte
- Reproducir a selección
Cola
Engadir á cola
Excluir o que non é música
@@ -126,7 +125,6 @@
Ascendente
Descendente
Ecualizador
- Aleatorio seleccionado
Frecuencia de mostraxe
Acerca de
Monitorizando cambios na túa biblioteca…
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index b4014ff97..97ffbd5ca 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -100,8 +100,6 @@
%s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता।
लोड किए गए गाने: %d
अवरोही
- चयनित चलाएँ
- फेरबदल का चयन किया गया
स्थिति साफ की गई
स्थिति सहेजी गई
लायब्रेरी टैब की दृश्यता और क्रम बदलें
@@ -299,4 +297,6 @@
बुद्धिमान छंटाई
संख्याओं या \"the\" जैसे शब्दों से शुरू होने वाले नामों को सही ढंग से क्रमबद्ध करें (अंग्रेजी भाषा के संगीत के साथ सबसे अच्छा काम करता है)
इसी गीत को चलाएं
+ दिशा
+ के अनुसार क्रमबद्ध करें
\ No newline at end of file
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index 1ce8b10cd..fecd2b6f2 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -25,7 +25,7 @@
Izvođač
Izvođači
Žanrovi
- Sortiraj
+ Razvrstaj
Naziv
Godina
Trajanje
@@ -178,7 +178,7 @@
Sve
Dodaj u popis pjesama
Dodano u popis pjesama
- Prikaži svojstva
+ Pogledaj svojstva
Idi na izvođača
Idi na album
Ostavi miješanje omogućeno kada se druga pjesma reproducira
@@ -212,7 +212,7 @@
Otvori popis pjesama
Žanr
Zarez (,)
- Ampersand (&)
+ Znak i (&)
Kompilacija uživo
Kompilacija remiksa
DJ kompilacije
@@ -247,15 +247,13 @@
Ponovo pretraži glazbu
Izbriši predmemoriju oznaka i ponovo potpuno učitaj glazbenu biblioteku (sporije, ali potpunije)
Odabrano: %d
- Promiješaj odabrane
- Reproduciraj odabrane
Reproduciraj iz žanra
Wiki
%1$s, %2$s
Resetiraj
ReplayGain izjednačavanje glasnoće
Mape
- Silazni
+ Silazno
Promijenite temu i boje aplikacije
Prilagodite kontrole i ponašanje korisničkog sučelja
Upravljajte učitavanjem glazbe i slika
@@ -292,4 +290,11 @@
Nema diska
Prisili kvadratične omote albuma
Odreži sve omote albuma na omjer 1:1
+ Pjesma
+ Pogledaj
+ Razvrstaj po
+ Reproduciraj pjesmu zasebno
+ Smjer
+ Slika odabira
+ Odabir
\ No newline at end of file
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 6c0b11c88..cec9b922a 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -75,7 +75,6 @@
Név
Dátum
Csökkenő
- Kiválasztott lejátszása
Új lejátszólista
Ismeretlen műfaj
Ugrás a következő dalra
@@ -142,7 +141,6 @@
Helyezze át ezt a dalt
%s előadó fotója
Teljes időtartam: %s
- Kiválasztottak keverése
UI vezérlők és viselkedés testreszabása
A könyvtárfülek láthatóságának és sorrendjének módosítása
A tétel részleteiből történő lejátszáskor
@@ -299,4 +297,8 @@
Dal
Megnéz
Dal lejátszása önmagában
+ Irány
+ Rendezés
+ Kiválasztás
+ Kép kiválasztás
\ No newline at end of file
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 525ed9115..21f166a7e 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -183,8 +183,6 @@
Muat ulang otomatis
Selalu muat ulang pustaka musik saat terjadi perubahan (membutuhkan notifikasi tetap)
Perilaku
- Putar yang dipilih
- Acak yang dipilih
Mode bundar
Aktifkan sudut yang bundar pada elemen UI tambahan (mewajibkan sampul album bersudut bundar)
Koma (,)
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 483d5b02f..1a4bf8938 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -255,8 +255,6 @@
Impossibile salvare
Svuota la cache dei tag e ricarica completamente la libreria musicale (più lento, ma più completo)
Impossibile svuotare
- Mescola selezionati
- Riproduci selezionati
%d selezionati
Riproduci dal genere
Wiki
@@ -304,4 +302,5 @@
Brano
Visualizza
Riproduci brano da solo
+ Ordina per
\ No newline at end of file
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index 31b5677fd..e85f5a07e 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -1,9 +1,9 @@
- מוזיקה בטעינה
- מוזיקה בטעינה
+ מוזיקה נטענת
+ מוזיקה נטענת
לנסות שוב
- מתבצעת סריקה בספריית המוזיקה שלך
+ ספריית המוזיקה שלך נסרקת
כל השירים
אלבומים
אלבום חי
@@ -17,17 +17,17 @@
סינגל חי
אוסף
אוסף חי
- אוספי רמיקסים
+ אוסף רמיקסים
פסקולים
פסקול
מיקסטייפים
- מיקס
+ מיקס DJ
חי
רמיקסים
אומן
אומנים
- סוגה
- סוגות
+ ז\'אנר
+ ז\'אנרים
סינון
הכל
תאריך
@@ -40,15 +40,13 @@
מושמע כעת
איקוולייזר
ניגון
- ניגון הנבחרים
ערבוב
- ערבוב הנבחרים
ניגון הבא
הוספה לתור
מעבר לאלבום
הצגת מאפיינים
מאפייני שיר
- תבנית
+ פורמט
גודל
קצב סיביות
קצב דגימה
@@ -62,11 +60,11 @@
גרסה
קוד מקור
ויקי
- רישיונות
+ רשיונות
סטטיסטיקות ספרייה
צפייה ושליטה בהשמעת המוזיקה
- טוען את ספריית המוזיקה שלך…
- סורק את ספריית המוזיקה שלך כדי לאתר שינויים…
+ ספריית המוזיקה שלך נטענת…
+ ספריית המוזיקה שלך נסרקת לאיתור שינויים…
התווסף לתור
מפותח על ידי אלכסנדר קייפהארט
חיפוש בספרייה שלך…
@@ -80,7 +78,7 @@
שימוש בערכת נושא שחורה לגמרי
מצב מעוגל
התאמה אישית
- התאמת רכיבים והתנהגות ממשק המשתמש
+ התאמת רכיבי והתנהגות הממשק
תצוגה
לשוניות ספרייה
פעולת התראות מותאמת אישית
@@ -93,19 +91,19 @@
ניגון מכל השירים
ניגון מאלבום
ניגון מהאומן
- ניגון מסוגה
- לזכור ערבוב
+ ניגון מז\'אנר
+ זכירת ערבוב
המשך ערבוב בעת הפעלת שיר חדש
תוכן
טעינה מחדש אוטומטית
- לטעון מחדש את הספרייה בכל פעם שהיא משתנה (דורש התראה קבועה)
- התעלמות מקובצי שמע שאינם מוזיקה, כמו הסכתים
+ טעינת הספרייה מחדש בכל פעם שהיא משתנה (דורש התראה קבועה)
+ התעלמות מקבצי אודיו שאינם מוזיקה, כמו הסכתים
מפרידים רבי-ערכים
פסיק (,)
נקודה-פסיק (;)
פלוס (+)
גם (&)
- הסתרת שיתופי פעולה
+ הסתרת משתפי~ות פעולה
הצגת אומנים שמצויינים ישירות בקרדיטים של אלבום בלבד (עובד באופן מיטבי על ספריות מתויגות היטב)
עטיפות אלבום
כבוי
@@ -118,7 +116,7 @@
עצירה בעת חזרה
ReplayGain
העדפת אלבום
- מגבר עוצמת נגינה מחדש
+ מגבר ReplayGain
התאמה עם תגיות
מיקסטייפ
נגן מוזיקה פשוט והגיוני לאנדרואיד.
@@ -136,24 +134,24 @@
שם קובץ
ערבוב
המצב שוחזר
- על אודות
+ אודות
הגדרות
אוטומטי
הפעלת פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות)
שינוי מראה וסדר לשוניות הספרייה
פעולת סרגל השמעה מותאמת אישית
- הגדרת טעינת המוזיקה והתמונות
+ הגדרת אופן טעינת מוזיקה ותמונות
מוזיקה
אי-הכללת תוכן שאינו מוזיקה
התאמת תווים המציינים ערכי תגית מרובים
קו נטוי (/)
אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים.
איכות גבוהה
- התעלמות ממילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית)
+ התעלמות ממספרים או מילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית)
תמונות
הגדרת הצליל והניגון
תמיד להתחיל לנגן ברגע שמחוברות אזניות (עלול לא לעבוד בכל המערכות)
- השהיה עם חזרה על שיר
+ השהייה עם חזרה על שיר
העדפת רצועה
אסטרטגיית ReplayGain
העדפת אלבום אם אחד מופעל
@@ -162,7 +160,7 @@
רשימת השמעה חדשה
הוספה לרשימת השמעה
לתת
- רשימת השמעה
+ רשימת השמעה (פלייליסט)
רשימות השמעה
מחיקה
שינוי שם
@@ -172,8 +170,8 @@
לא ניתן לנקות את המצב
כתום
תיקיות מוזיקה
- טעינה מחדש של ספריית המוזיקה, במידה וניתן יעשה שימוש במטמון תגיות
- סריקה מחדש אחר מוזיקה
+ טעינה מחדש של ספריית המוזיקה, במידה וניתן ייעשה שימוש בתגיות מהמטמון
+ סריקת מוסיקה מחדש
שמירת מצב הנגינה
לא ניתן לשמור את המצב
Auxio צריך הרשאות על מנת לקרוא את ספריית המוזיקה שלך
@@ -185,7 +183,7 @@
אלבומים טעונים: %d
סוגות טעונות: %d
המצב נוקה
- ספרייה
+ ספריה
שמירת מצב הנגינה הנוכחי כעת
לא נמצא יישום שיכול לטפל במשימה זו
אין תיקיות
@@ -209,7 +207,7 @@
דינמי
המוזיקה שלך בטעינה (%1$d/%2$d)…
דיסק %d
- ניהול תיקיות המוזיקה לטעינה
+ ניהול המקומות שמהם תיטען מוזיקה
אין שירים
ורוד
נוצרה רשימת השמעה
@@ -235,7 +233,7 @@
- שני אלבומים
- %d אלבומים
- שונה שם לרשימת השמעה
+ שונה שם רשימת ההשמעה
רשימת השמעה נמחקה
נוסף לרשימת השמעה
ערבוב כל השירים
@@ -244,7 +242,7 @@
תמונת רשימת השמעה עבור %s
אדום
ירוק
- ניתוב הורה
+ נתיב הורה
לא ניתן לשחזר את המצב
רצועה %d
יצירת רשימת השמעה חדשה
@@ -260,4 +258,19 @@
ירוק עמוק
צהוב
מחיקת %s\? פעולה זו לא ניתן לביטול.
+ שיר
+ מיון חכם
+ הצגה
+ הכרחת עטיפות אלבום מרובעות
+ ריקון מטמון התגיות וטעינת ספריית המוזיקה מחדש במלואה (איטי יותר, אך יותר שלם)
+ ניקוי מצב הנגינה הקודם שנשמר (אם קיים)
+ מיון על פי
+ כיוון
+ חיתוך כל עטיפות האלבומים ליחס של 1:1
+ מוזיקה לא תיטען מהתיקיות שנוספו.
+ מוזיקה תיטען רק מהתיקיות שנוספו.
+ מופיע~ה ב-
+ ניגון השיר בלבד
+ אזהרה: שינוי המגבר לערך חיובי גבוה עלול לגרום לשיאים בחלק מרצועות האודיו
+ שחזור מצב נגינה
\ No newline at end of file
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 21849864b..7c011f04e 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -63,7 +63,6 @@
降順
再生
シャフル
- 選択曲をシャフル
次に再生
再生待ちに追加
オーディオ形式
@@ -178,7 +177,6 @@
リミックスEP
リミックス
ジャンル
- 選択曲を再生
プロパティを見る
再生待ち
ライブラリ統計
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 6716d4d77..70aa17ba9 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -251,8 +251,6 @@
- %d 아티스트
태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함)
- 선택한 재생
- 선택한 셔플
%d 선택됨
재설정
위키
@@ -295,4 +293,13 @@
디스크 없음
재생목록이 삭제되었습니다
%s 수정 중
+ 노래
+ 보다
+ 포스 스퀘어 앨범 커버
+ 모든 앨범 표지를 1:1 가로세로 비율로 자르기
+ 노래 따로 재생
+ 방향
+ 정렬 기준
+ 선택 이미지
+ 선택
\ No newline at end of file
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index caa995e31..cfcbccdcf 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -249,8 +249,6 @@
Perskenuoti muziką
Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta)
%d pasirinkta
- Pasirinktas grojimas
- Pasirinktas maišymas
Groti iš žanro
Viki
%1$s, %2$s
@@ -297,4 +295,6 @@
Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento
Priversti kvadratinių albumų viršelius
Groti dainą pačią
+ Rūšiuoti pagal
+ Kryptis
\ No newline at end of file
diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml
index 82f0ffe92..f1ae5be53 100644
--- a/app/src/main/res/values-ml/strings.xml
+++ b/app/src/main/res/values-ml/strings.xml
@@ -1,6 +1,5 @@
- തിരഞ്ഞെടുത്തു കളിക്കുക
രക്ഷിക്കുക
പെരുമാറ്റം
ഉള്ളടക്കം
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index 5913008c0..c276bd0bd 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -43,7 +43,6 @@
Sporantall
Kø
Spill neste
- Omstokking valgt
Bibliotek
Kunne ikke lagre tilstand
@@ -275,7 +274,6 @@
Tonekontroll
Endre gjentagelsesmodus
Spill
- Spill valgte
Lagre
Laster inn musikkbiblioteket ditt …
Ved avspilling fra bibliotek
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 4d29de5df..c0ecc5905 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -209,7 +209,6 @@
Toon alleen artiesten die rechtstreeks op een album worden genoemd (werkt het beste op goed getagde bibliotheken)
Sorteer namen die beginnen met cijfers of woorden zoals \"de\" correct (werkt het beste met Engelstalige muziek)
Stop met afspelen
- Geselecteerd afspelen
Uw muziekbibliotheek wordt geladen…
Gedrag
Remix compilatie
@@ -279,7 +278,6 @@
- %d artiest
- %d artiesten
- Shuffle geselecteerd
Intelligent sorteren
Verschijnt op
Afspeellijsten
diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml
index 0c83a6bfa..175c77cdb 100644
--- a/app/src/main/res/values-pa/strings.xml
+++ b/app/src/main/res/values-pa/strings.xml
@@ -49,7 +49,6 @@
ਇਕੋਲਾਈਜ਼ਰ
ਚਲਾਓ
ਸ਼ਫਲ
- ਸ਼ਫਲ ਚੁਣਿਆ ਗਿਆ
ਕਤਾਰ
ਅਗਲਾ ਚਲਾਓ
ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ
@@ -76,7 +75,6 @@
ਖੋਜੋ
ਗੀਤ ਦੀ ਗਿਣਤੀ
ਘਟਦੇ ਹੋਏ
- ਚੁਣਿਆ ਹੋਇਆ ਚਲਾਓ
ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ
ਫਾਈਲ ਦਾ ਨਾਮ
ਬਿੱਟ ਰੇਟ
@@ -292,4 +290,6 @@
ਗੀਤ
ਵੇਖੋ
ਇਸੇ ਗੀਤ ਨੂੰ ਚਲਾਓ
+ ਸੌਰਟ ਕਰੋ
+ ਦਿਸ਼ਾ
\ No newline at end of file
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index a472dc577..737838fe6 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -255,8 +255,6 @@
Stan odtwarzania
Obrazy
Zarządzaj dźwiękiem i odtwarzaniem muzyki
- Odtwórz wybrane
- Wybrane losowo
Wybrano %d
Wyrównanie głośności (ReplayGain)
Resetuj
@@ -305,4 +303,6 @@
Piosenka
Odtwarzanie utworu samodzielnie
Widok
+ Sortuj według
+ Kierunek
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index ec43dcdc8..ef6191ce3 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -253,8 +253,6 @@
Não foi possível salvar a lista
Ocultar artistas colaboradores
Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos)
- Tocar selecionada(s)
- Aleatorizar selecionadas
%d Selecionadas
Wiki
Redefinir
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 8140c3ad1..3a297776d 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -217,8 +217,6 @@
A carregar a sua biblioteca de músicas… (%1$d/%2$d)
Retroceder antes de voltar
Parar reprodução
- Reproduzir selecionada(s)
- Aleatorizar selecionadas
Caminho principal
Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas)
%d Selecionadas
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 793b4ec7d..4351fd565 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -134,11 +134,9 @@
Afişa
Utilizați o temă întunecată pur-negru
Coperți rotunjite ale albumelor
- Redare selecție
Listă de redare
Liste de redare
Descrescător
- Selecție aleatorie aleasă
Treceți la următoarea
Redă de la artist
Redă din genul
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 6f311d10a..6e93fb54f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -148,15 +148,15 @@
ОК
При воспроизведении из сведений
Воспроизведение с показанного элемента
- Номер песни
+ Номер трека
Битрейт
Диск
Трек
Позиция восстановлена
Отмена
Внимание: Изменение предусиления на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках.
- Свойства
- Свойства песни
+ Сведения
+ Свойства трека
Путь
Формат
Размер
@@ -258,8 +258,6 @@
Не удалось очистить состояние
Не удалось сохранить состояние
Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\).
- Воспроизвести выбранное
- Перемешать выбранное
%d выбрано
Вики
Сбросить
@@ -304,7 +302,11 @@
Поделиться
Использовать квадратные обложки альбомов
Обрезать все обложки альбомов до соотношения сторон 1:1
- Песня
+ Трек
Вид
Воспроизвести трек отдельно
+ Сортировать по
+ Направление
+ Выберите
+ Выберите изображение
\ No newline at end of file
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index e9547d169..60f9e5c97 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -43,7 +43,6 @@
Nu spelar
Utjämnare
Spela
- Spela utvalda
Blanda
Kö
Spela nästa
@@ -99,7 +98,6 @@
Alla
Disk
Sortera
- Blanda utvalda
Lägg till kö
Filnamn
Lägg till
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 86915531e..559ef2336 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -196,8 +196,6 @@
Tekliler
Tekli
Karışık kaset
- Seçileni çal
- Karışık seçildi
Canlı derleme
Remiks derlemeler
Ekolayzır
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index a8c4959d6..ddeb997f0 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -54,7 +54,6 @@
- %d альбомів
- %d альбомів
- Перемішати вибране
Ім\'я файлу
Формат
Добре
@@ -83,7 +82,6 @@
Шлях до каталогу
Екран
Рік
- Відтворити вибране
Обкладинки альбомів
Приховати співавторів
Вимкнено
@@ -304,4 +302,8 @@
Пісня
Переглянути
Відтворити пісню окремо
+ Сортувати за
+ Напрямок
+ Вибрати
+ Вибрати зображення
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 1b91a7870..c5e17d0c4 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -249,8 +249,6 @@
无法清除状态
重新扫描音乐
清除标签缓存并完全重新加载音乐库(更慢,但更完整)
- 随机播放所选
- 播放所选
选中了 %d 首
按流派播放
Wiki
@@ -298,4 +296,8 @@
歌曲
查看
自行播放歌曲
+ 排序依据
+ 说明
+ 选择
+ 选择图片
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 064135105..140539c9b 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -23,6 +23,7 @@
48dp
56dp
64dp
+ 64dp
72dp
24dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f2bedcee8..31700900b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -16,6 +16,8 @@
Monitoring music library
Retry
+
+ More
Grant
@@ -112,9 +114,7 @@
Now playing
Equalizer
Play
- Play selected
Shuffle
- Shuffle selected
Queue
Play next
@@ -122,7 +122,6 @@
Add to playlist
-
Go to artist
Go to album
View properties
@@ -167,6 +166,14 @@
Licenses
Library statistics
+ Selection
+
+ Error information
+
+ Copied
+
+ Report
+
@@ -335,6 +342,7 @@
Artist image for %s
Genre image for %s
Playlist image for %s
+ Selection image
diff --git a/app/src/main/res/values/styles_core.xml b/app/src/main/res/values/styles_core.xml
index 92e6d6818..f43098404 100644
--- a/app/src/main/res/values/styles_core.xml
+++ b/app/src/main/res/values/styles_core.xml
@@ -25,6 +25,7 @@
- @style/Widget.Auxio.LinearProgressIndicator
- @style/Widget.Auxio.BottomSheet
- @style/Widget.Auxio.BottomSheet.Dialog
+ - @style/Widget.Auxio.BottomSheet.Handle
- @style/TextAppearance.Auxio.DisplayLarge
- @style/TextAppearance.Auxio.DisplayMedium
@@ -51,8 +52,6 @@
- none
- false
- true
-
-
- @color/sel_compat_ripple
- ?attr/colorOnSurfaceVariant
- ?attr/colorPrimary
diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml
index 98228e1aa..5106e12b9 100644
--- a/app/src/main/res/values/styles_ui.xml
+++ b/app/src/main/res/values/styles_ui.xml
@@ -59,6 +59,10 @@
- @anim/bottom_sheet_slide_out
+
+