diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f0aff366e..f106a3aac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,8 +23,8 @@ jobs: cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - # - name: Test app with Gradle - # run: ./gradlew app:testDebug + - name: Test app with Gradle + run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e66ee13..f3645510f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,17 @@ ## dev +#### What's Fixed +- Fixed app restart being required when changing intelligent sorting +or music separator settings + +## 3.2.0 + #### What's New - Item and sort menus have been refreshed with a cleaner look - Added ability to sort playlists - Added option to play song by itself in library/item details +- Added error details when music loading fails #### What's Improved - Made "Add to Playlist" action more prominent in selection toolbar @@ -15,9 +22,6 @@ aspect ratio setting #### What's Fixed - Playlist detail view now respects playback settings -#### Dev/Meta -- Unified navigation graph - ## 3.1.4 #### What's Fixed diff --git a/README.md b/README.md index a324ffd3c..8b16f36ed 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index f088245c7..5559d0b2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.1.4" - versionCode 34 + versionName "3.2.0" + versionCode 35 minSdk 24 targetSdk 34 @@ -88,7 +88,7 @@ dependencies { implementation "androidx.core:core-ktx:1.10.1" implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.activity:activity-ktx:1.7.2" - implementation "androidx.fragment:fragment-ktx:1.6.0" + implementation "androidx.fragment:fragment-ktx:1.6.1" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -114,13 +114,11 @@ dependencies { implementation "androidx.media:media:1.6.0" // Preferences - implementation "androidx.preference:preference-ktx:1.2.0" + implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-alpha02' + def room_version = '2.6.0-alpha03' implementation "androidx.room:room-runtime:$room_version" - // I have no clue why, but using KSP breaks the playlist database definition. - //noinspection KaptUsageInsteadOfKsp ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -136,7 +134,7 @@ dependencies { // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.10.0-alpha05" + implementation "com.google.android.material:material:1.10.0-alpha06" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" @@ -144,9 +142,15 @@ dependencies { implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version" + // Logging + implementation 'com.jakewharton.timber:timber:5.0.1' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" + testImplementation "io.mockk:mockk:1.13.7" + testImplementation "org.robolectric:robolectric:4.9" + testImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt deleted file mode 100644 index a0ba54a3d..000000000 --- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * StubTest.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 - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class StubTest { - // TODO: Make tests - @Test - fun useAppContext() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("org.oxycblt.auxio.debug", appContext.packageName) - } -} diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index 214f6ac62..577036ec4 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1737,16 +1737,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo final boolean shouldHandleGestureInsets = VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; - // If were not handling insets at all, don't apply the listener. - if (!paddingBottomSystemWindowInsets - && !paddingLeftSystemWindowInsets - && !paddingRightSystemWindowInsets - && !marginLeftSystemWindowInsets - && !marginRightSystemWindowInsets - && !marginTopSystemWindowInsets - && !shouldHandleGestureInsets) { - return; - } + // 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) + ViewUtils.doOnApplyWindowInsets( child, new ViewUtils.OnApplyWindowInsetsListener() { @@ -1758,7 +1752,16 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo Insets mandatoryGestureInsets = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); - insetTop = systemBarInsets.top; + // MODIFICATION: Fix second order change of edge-to-edge fix where dialogs will not + // use the nice-looking inset animation and instead blindly shift themselves downwards. + // insetTop = systemBarInsets.top; + + // 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) + // Intentionally uses getSystemWindowInsetBottom to apply padding properly when + // adjustResize is used as the windowSoftInputMode. + insetBottom = insets.getSystemWindowInsetBottom(); boolean isRtl = ViewUtils.isLayoutRtl(view); @@ -1767,9 +1770,6 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo int rightPadding = view.getPaddingRight(); if (paddingBottomSystemWindowInsets) { - // Intentionally uses getSystemWindowInsetBottom to apply padding properly when - // adjustResize is used as the windowSoftInputMode. - insetBottom = insets.getSystemWindowInsetBottom(); bottomPadding = initialPadding.bottom + insetBottom; } @@ -1810,11 +1810,10 @@ public class BackportBottomSheetBehavior 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 @@ + + + + + + @@ -178,7 +197,7 @@ + @@ -218,13 +240,13 @@ + @@ -250,6 +275,9 @@ + @@ -261,13 +289,13 @@ + @@ -307,13 +338,13 @@ + @@ -356,7 +390,7 @@ + + + + \ 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 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 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 + +