diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 76abe25e7..de42bfe09 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -39,8 +39,8 @@ If you have knowledge of Android/Kotlin, feel free to to contribute to the proje - If you want to help out with an existing bug report, comment on the issue that you want to fix saying that you are going to try your hand at it. - If you want to add something, its recommended to open up an issue for what you want to change before you start working on it. That way I can determine if the addition will be merged in the first place, and generally gives a heads-up overall. - Do not bring non-free software into the project, such as Binary Blobs. -- Stick to [F-Droid Including Guidelines](https://f-droid.org/wiki/page/Inclusion_Policy) -- Make sure you stick to Auxio's styling with [ktlint](https://github.com/pinterest/ktlint). `ktlintformat` should run on every build. +- Stick to [F-Droid Inclusion Guidelines](https://f-droid.org/wiki/page/Inclusion_Policy) +- Make sure you stick to Auxio's styling, which should be auto-formatted on every build. - Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged. -- Java code will **NOT** be accepted. Kotlin only. +- Only **Kotlin** will be accepted, except for the case that a UI component must be vendored in the project. - Keep your code up the date with the upstream and continue to maintain it after you create the PR. This makes it less of a hassle to merge. diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml index 652dba0b8..7b94b9916 100644 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -34,6 +34,7 @@ body: attributes: label: What android version do you use? options: + - Android 14 - Android 13 - Android 12L - Android 12 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 2063c3684..0dcca4f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## dev + +#### What's New +- Added ability to rewind/skip tracks by swiping back/forward +- Added support for demo release type + +#### What's Changed +- Albums linked to an artist only as a collaborator are no longer included +in an artist's album count + +#### What's Fixed +- Fixed certain FLAC files failing to play on some devices + + +## 3.2.1 + +#### What's Improved +- Added support for native M4A multi-value tags based on duplicate atoms + +#### What's Fixed +- Fixed app restart being required when changing intelligent sorting +or music separator settings +- Fixed widget/notification actions not working on Android 14 +- Fixed app crash when using hebrew language +- Fixed app crash when adding to a playlist while in the playlist detail view +- Fixed music loading failing in some cases on Android 14 + +## 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 +- Fixed notification album covers not updating after changing the cover +aspect ratio setting + +#### What's Fixed +- Playlist detail view now respects playback settings + + +#### Dev/Meta +- Revamped navigation backend + +## 3.1.4 + +#### What's Fixed +- Fixed issue where one could not navigate to settings after navigating elsewhere +- Fixed the queue list being non-scrollable in certain cases +- Fixed negative ReplayGain adjustments not being applied + ## 3.1.3 #### What's New diff --git a/README.md b/README.md index 79a600389..4c867895b 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases @@ -21,9 +21,7 @@ ## About -Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of [ExoPlayer](https://exoplayer.dev/), Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.** - -I primarily built Auxio for myself, but you can use it too, I guess. +Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.** **The default branch is the development version of the repository. For a stable version, see the master branch.** @@ -42,7 +40,7 @@ I primarily built Auxio for myself, but you can use it too, I guess. ## Features -- [ExoPlayer](https://exoplayer.dev/)-based playback +- Playback based on [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer) - Snappy UI derived from the latest Material Design guidelines - Opinionated UX that prioritizes ease of use over edge cases - Customizable behavior @@ -60,21 +58,20 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers (Unless you want them. Then you can.) +- No rounded album covers (by default) ## Permissions -- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files -- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background +- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files +- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background ## Building -Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to -the build process: +Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process: 1. `cmake` and `ninja-build` must be installed before building the project. 2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly download the external code. -3. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that +3. You are **unable** to build this project on windows, as the custom Media3 build runs shell scripts that will only work on unix-based systems. ## Contributing diff --git a/app/build.gradle b/app/build.gradle index c38c26e0d..f414337b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,7 +6,8 @@ plugins { id "kotlin-parcelize" id "dagger.hilt.android.plugin" id "kotlin-kapt" - id 'org.jetbrains.kotlin.android' + id "com.google.devtools.ksp" + id "org.jetbrains.kotlin.android" } android { @@ -15,13 +16,13 @@ android { // it here so that binary stripping will work. // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the // NDK use is unified - ndkVersion = "23.2.8568313" + ndkVersion = "25.2.9519653" namespace "org.oxycblt.auxio" defaultConfig { applicationId namespace - versionName "3.1.3" - versionCode 33 + versionName "3.2.1" + versionCode 36 minSdk 24 targetSdk 34 @@ -30,6 +31,7 @@ android { } compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -77,17 +79,17 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - def coroutines_version = '1.7.1' + def coroutines_version = '1.7.2' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version" // --- SUPPORT --- // General - implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.core:core-ktx:1.12.0" 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.activity:activity-ktx:1.8.2" + implementation "androidx.fragment:fragment-ktx:1.6.2" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -99,7 +101,7 @@ dependencies { implementation "androidx.viewpager2:viewpager2:1.0.0" // Lifecycle - def lifecycle_version = "2.6.1" + def lifecycle_version = "2.6.2" implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" @@ -110,15 +112,15 @@ dependencies { implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version" // Media - implementation "androidx.media:media:1.6.0" + implementation "androidx.media:media:1.7.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.1' implementation "androidx.room:room-runtime:$room_version" - kapt "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" // --- THIRD PARTY --- @@ -126,6 +128,7 @@ dependencies { // Exoplayer (Vendored) implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" // Image loading implementation 'io.coil-kt:coil-base:2.4.0' @@ -133,7 +136,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-alpha04" + implementation "com.google.android.material:material:1.10.0" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" @@ -141,9 +144,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.11' + 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 ab55a48dc..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 @@ -1671,9 +1671,8 @@ MaterialBottomContainerBackHelper getBackHelper() { @Nullable @VisibleForTesting View findScrollingChild(View view) { - if (view.getVisibility() != View.VISIBLE) { - return null; - } + // MODIFICATION: Remove visibility check that broke nested scrolling in the queue sheet + // due to it being set to invisible when completely hidden if (ViewCompat.isNestedScrollingEnabled(view)) { return view; } @@ -1738,16 +1737,10 @@ private void setWindowInsetsListener(@NonNull View child) { 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() { @@ -1759,7 +1752,16 @@ public WindowInsetsCompat onApplyWindowInsets( 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); @@ -1768,9 +1770,6 @@ public WindowInsetsCompat onApplyWindowInsets( 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; } @@ -1811,11 +1810,10 @@ public WindowInsetsCompat onApplyWindowInsets( 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/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index d0bff5315..54d59eb50 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -65,14 +65,14 @@ object IntegerTable { const val REPEAT_MODE_ALL = 0xA101 /** RepeatMode.TRACK */ const val REPEAT_MODE_TRACK = 0xA102 - /** PlaybackMode.IN_GENRE */ - const val PLAYBACK_MODE_IN_GENRE = 0xA103 - /** PlaybackMode.IN_ARTIST */ - const val PLAYBACK_MODE_IN_ARTIST = 0xA104 - /** PlaybackMode.IN_ALBUM */ - const val PLAYBACK_MODE_IN_ALBUM = 0xA105 - /** PlaybackMode.ALL_SONGS */ - const val PLAYBACK_MODE_ALL_SONGS = 0xA106 + // /** PlaybackMode.IN_GENRE (No longer used but still reserved) */ + // const val PLAYBACK_MODE_IN_GENRE = 0xA103 + // /** PlaybackMode.IN_ARTIST (No longer used but still reserved) */ + // const val PLAYBACK_MODE_IN_ARTIST = 0xA104 + // /** PlaybackMode.IN_ALBUM (No longer used but still reserved) */ + // const val PLAYBACK_MODE_IN_ALBUM = 0xA105 + // /** PlaybackMode.ALL_SONGS (No longer used but still reserved) */ + // const val PLAYBACK_MODE_ALL_SONGS = 0xA106 /** MusicMode.SONGS */ const val MUSIC_MODE_SONGS = 0xA10B /** MusicMode.ALBUMS */ @@ -101,8 +101,6 @@ object IntegerTable { const val SORT_BY_TRACK = 0xA117 /** Sort.Mode.ByDateAdded */ const val SORT_BY_DATE_ADDED = 0xA118 - /** Sort.Mode.None */ - const val SORT_BY_NONE = 0xA11F /** ReplayGainMode.Off (No longer used but still reserved) */ // const val REPLAY_GAIN_MODE_OFF = 0xA110 /** ReplayGainMode.Track */ @@ -123,4 +121,16 @@ object IntegerTable { const val COVER_MODE_MEDIA_STORE = 0xA11D /** CoverMode.Quality */ const val COVER_MODE_QUALITY = 0xA11E + /** PlaySong.FromAll */ + const val PLAY_SONG_FROM_ALL = 0xA11F + /** PlaySong.FromAlbum */ + const val PLAY_SONG_FROM_ALBUM = 0xA120 + /** PlaySong.FromArtist */ + const val PLAY_SONG_FROM_ARTIST = 0xA121 + /** PlaySong.FromGenre */ + const val PLAY_SONG_FROM_GENRE = 0xA122 + /** PlaySong.FromPlaylist */ + const val PLAY_SONG_FROM_PLAYLIST = 0xA123 + /** PlaySong.ByItself */ + const val PLAY_SONG_BY_ITSELF = 0xA124 } diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 725f60444..c98d89cdd 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -68,8 +68,8 @@ class MainActivity : AppCompatActivity() { logD("Activity created") } - override fun onStart() { - super.onStart() + override fun onResume() { + super.onResume() startService(Intent(this, IndexerService::class.java)) startService(Intent(this, PlaybackService::class.java)) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 9b6b47a08..ed1b47c7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -26,11 +26,9 @@ import androidx.activity.OnBackPressedCallback import androidx.core.view.ViewCompat import androidx.core.view.isInvisible import androidx.core.view.updatePadding -import androidx.fragment.app.FragmentContainerView 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 import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable @@ -40,44 +38,47 @@ import kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.detail.Show +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.home.Outer +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.Panel +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 import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A wrapper around the home fragment that shows the playback fragment and controls the more - * high-level navigation features. + * A wrapper around the home fragment that shows the playback fragment and high-level navigation. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class MainFragment : - ViewBindingFragment(), - ViewTreeObserver.OnPreDrawListener, - NavController.OnDestinationChangedListener { - private val playbackModel: PlaybackViewModel by activityViewModels() - private val selectionModel: SelectionViewModel by activityViewModels() + ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener { private val detailModel: DetailViewModel by activityViewModels() + private val homeModel: HomeViewModel by activityViewModels() + private val listModel: ListViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() private var sheetBackCallback: SheetBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null - private var exploreBackCallback: ExploreBackPressedCallback? = 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) @@ -100,28 +101,19 @@ class MainFragment : // Currently all back press callbacks are handled in MainFragment, as it's not guaranteed // that instantiating these callbacks in their respective fragments would result in the // correct order. - val sheetBackCallback = + sheetBackCallback = SheetBackPressedCallback( - playbackSheetBehavior = playbackSheetBehavior, - queueSheetBehavior = queueSheetBehavior) - .also { sheetBackCallback = it } + playbackSheetBehavior = playbackSheetBehavior, + queueSheetBehavior = queueSheetBehavior) val detailBackCallback = DetailBackPressedCallback(detailModel).also { detailBackCallback = it } val selectionBackCallback = - SelectionBackPressedCallback(selectionModel).also { selectionBackCallback = it } - val exploreBackCallback = - ExploreBackPressedCallback(binding.exploreNavHost).also { exploreBackCallback = it } + SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } + + selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) // --- UI SETUP --- val context = requireActivity() - // Override the back pressed listener so we can map back navigation to collapsing - // navigation, navigation out of detail views, etc. - context.onBackPressedDispatcher.apply { - addCallback(viewLifecycleOwner, exploreBackCallback) - addCallback(viewLifecycleOwner, selectionBackCallback) - addCallback(viewLifecycleOwner, detailBackCallback) - addCallback(viewLifecycleOwner, sheetBackCallback) - } binding.root.setOnApplyWindowInsetsListener { _, insets -> lastInsets = insets @@ -159,8 +151,14 @@ 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(selectionModel.selected, selectionBackCallback::invalidateEnabled) + collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) + collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.openPanel.flow, ::handlePanel) } @@ -170,17 +168,30 @@ 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) } + override fun onResume() { + super.onResume() + // Override the back pressed listener so we can map back navigation to collapsing + // navigation, navigation out of detail views, etc. We have to do this here in + // onResume or otherwise the FragmentManager will have precedence. + requireActivity().onBackPressedDispatcher.apply { + addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback)) + addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback)) + addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback)) + } + } + 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) } @@ -189,10 +200,14 @@ class MainFragment : sheetBackCallback = null detailBackCallback = null selectionBackCallback = null - exploreBackCallback = null + selectionNavigationListener = null } override fun onPreDraw(): Boolean { + // TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom + // sheets continually getting stuck. I need something with even more frequent updates, + // or otherwise bottom sheets get stuck. + // We overload CoordinatorLayout far too much to rely on any of it's typical // listener functionality. Just update all transitions before every draw. Should // probably be cheap enough. @@ -283,21 +298,29 @@ 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. - requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" } - .invalidateEnabled() - 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 -> {} } - selectionModel.drop() + } + + private fun handleShowOuter(outer: Outer?) { + val directions = + when (outer) { + is Outer.Settings -> MainFragmentDirections.preferences() + is Outer.About -> MainFragmentDirections.about() + null -> return + } + findNavController().navigateSafe(directions) + homeModel.showOuter.consume() } private fun updateSong(song: Song?) { @@ -308,13 +331,13 @@ class MainFragment : } } - private fun handlePanel(panel: Panel?) { + private fun handlePanel(panel: OpenPanel?) { if (panel == null) return logD("Trying to update panel to $panel") when (panel) { - is Panel.Main -> tryClosePlaybackPanel() - is Panel.Playback -> tryOpenPlaybackPanel() - is Panel.Queue -> tryOpenQueuePanel() + OpenPanel.MAIN -> tryClosePlaybackPanel() + OpenPanel.PLAYBACK -> tryOpenPlaybackPanel() + OpenPanel.QUEUE -> tryOpenQueuePanel() } playbackModel.openPanel.consume() } @@ -458,11 +481,10 @@ class MainFragment : } } - private inner class SelectionBackPressedCallback( - private val selectionModel: SelectionViewModel - ) : OnBackPressedCallback(false) { + private inner class SelectionBackPressedCallback(private val listModel: ListViewModel) : + OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (selectionModel.drop()) { + if (listModel.dropSelection()) { logD("Dropped selection") } } @@ -471,23 +493,4 @@ class MainFragment : isEnabled = selection.isNotEmpty() } } - - private inner class ExploreBackPressedCallback( - private val exploreNavHost: FragmentContainerView - ) : OnBackPressedCallback(false) { - // Note: We cannot cache the NavController in a variable since it's current destination - // value goes stale for some reason. - - override fun handleOnBackPressed() { - exploreNavHost.findNavController().navigateUp() - logD("Forwarded back navigation to explore nav host") - } - - fun invalidateEnabled() { - val exploreNavController = exploreNavHost.findNavController() - isEnabled = - exploreNavController.currentDestination?.id != - exploreNavController.graph.startDestinationId - } - } } 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 8bf635e5e..3fd4a6963 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -39,26 +37,24 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup -import org.oxycblt.auxio.util.share -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -71,10 +67,11 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailHeaderAdapter.Listener, DetailListAdapter.Listener { - override val detailModel: DetailViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() + // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. private val args: AlbumDetailFragmentArgs by navArgs() @@ -101,16 +98,18 @@ class AlbumDetailFragment : // --- UI SETUP -- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_album_detail) setNavigationOnClickListener { findNavController().navigateUp() } - setOnMenuItemClickListener(this@AlbumDetailFragment) + overrideOnOverflowMenuClick { + listModel.openMenu( + R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value)) + } } binding.detailRecycler.apply { adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { - val item = detailModel.albumList.value[it - 1] + val item = detailModel.albumSongList.value[it - 1] item is Divider || item is Header || item is Disc } else { true @@ -122,14 +121,14 @@ class AlbumDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setAlbum(args.albumUid) collectImmediately(detailModel.currentAlbum, ::updateAlbum) - collectImmediately(detailModel.albumList, ::updateList) + collectImmediately(detailModel.albumSongList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(listModel.menu.flow, ::handleMenu) + collectImmediately(listModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) - collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) + collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -138,52 +137,15 @@ class AlbumDetailFragment : binding.detailRecycler.adapter = null // 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.albumInstructions.consume() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - if (super.onMenuItemClick(item)) { - return true - } - - val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value) - return when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(currentAlbum) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(currentAlbum) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_go_artist -> { - onNavigateToParentArtist() - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(currentAlbum) - true - } - R.id.action_share -> { - requireContext().share(currentAlbum) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } + detailModel.albumSongInstructions.consume() } override fun onRealClick(item: Song) { - // There can only be one album, so a null mode and an ALBUMS mode will function the same. - playbackModel.playFrom(item, detailModel.playbackMode ?: MusicMode.ALBUMS) + playbackModel.play(item, detailModel.playInAlbumWith) } - override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_song_actions, item) + override fun onOpenMenu(item: Song) { + listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith) } override fun onPlay() { @@ -194,31 +156,8 @@ class AlbumDetailFragment : playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) } - override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_album_sort) { - // Select the corresponding sort mode option - val sort = detailModel.albumSongSort - unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true - // Select the corresponding sort direction option - val directionItemId = - when (sort.direction) { - Sort.Direction.ASCENDING -> R.id.option_sort_asc - Sort.Direction.DESCENDING -> R.id.option_sort_dec - } - unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true - setOnMenuItemClickListener { item -> - item.isChecked = !item.isChecked - detailModel.albumSongSort = - when (item.itemId) { - // Sort direction options - R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) - R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) - // Any other option is a sort mode - else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) - } - true - } - } + override fun onOpenSortMenu() { + findNavController().navigateSafe(AlbumDetailFragmentDirections.sort()) } override fun onNavigateToParentArtist() { @@ -236,7 +175,7 @@ class AlbumDetailFragment : } private fun updateList(list: List) { - albumListAdapter.update(list, detailModel.albumInstructions.consume()) + albumListAdapter.update(list, detailModel.albumSongInstructions.consume()) } private fun handleShow(show: Show?) { @@ -275,22 +214,20 @@ class AlbumDetailFragment : .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid)) } } - - // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") findNavController() .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid)) } - is Show.SongArtistDetails -> { + is Show.SongArtistDecision -> { logD("Navigating to artist choices for ${show.song}") findNavController() - .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.song.uid)) + .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid)) } - is Show.AlbumArtistDetails -> { + is Show.AlbumArtistDecision -> { logD("Navigating to artist choices for ${show.album}") findNavController() - .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.album.uid)) + .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid)) } is Show.GenreDetails, is Show.PlaylistDetails -> { @@ -300,6 +237,20 @@ class AlbumDetailFragment : } } + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + 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") + } + findNavController().navigateSafe(directions) + } + private fun updateSelection(selected: List) { albumListAdapter.setSelected(selected.toSet()) @@ -312,21 +263,20 @@ class AlbumDetailFragment : } } - private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Adding ${decision.songs.size} songs to a playlist") - findNavController() - .navigateSafe( - AlbumDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - musicModel.playlistDecision.consume() + private fun handlePlaylistDecision(decision: PlaylistDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} songs to a playlist") + AlbumDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } - is PlaylistDecision.New, - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + findNavController().navigateSafe(directions) } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { @@ -334,21 +284,25 @@ class AlbumDetailFragment : song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying) } - private fun handlePlayFromArtist(song: Song?) { - if (song == null) return - logD("Launching play from artist dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) - } - - private fun handlePlayFromGenre(song: Song?) { - if (song == null) return - logD("Launching play from genre dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaybackDecision.PlayFromArtist -> { + logD("Launching play from artist dialog for $decision") + AlbumDetailFragmentDirections.playFromArtist(decision.song.uid) + } + is PlaybackDecision.PlayFromGenre -> { + logD("Launching play from artist dialog for $decision") + AlbumDetailFragmentDirections.playFromGenre(decision.song.uid) + } + } + findNavController().navigateSafe(directions) } private fun scrollToAlbumSong(song: Song) { // Calculate where the item for the currently played song is - val pos = detailModel.albumList.value.indexOf(song) + val pos = detailModel.albumSongList.value.indexOf(song) if (pos != -1) { // Only scroll if the song is within this album. 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 86208424b..b0bc09386 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -39,8 +37,8 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music @@ -48,15 +46,14 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel 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.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup -import org.oxycblt.auxio.util.share -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -69,8 +66,8 @@ class ArtistDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, DetailListAdapter.Listener { - override val detailModel: DetailViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what artist to display is initially within the navigation arguments @@ -99,9 +96,12 @@ class ArtistDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@ArtistDetailFragment) + overrideOnOverflowMenuClick { + listModel.openMenu( + R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value)) + } } binding.detailRecycler.apply { @@ -109,7 +109,7 @@ class ArtistDetailFragment : (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { val item = - detailModel.artistList.value.getOrElse(it - 1) { + detailModel.artistSongList.value.getOrElse(it - 1) { return@setFullWidthLookup false } item is Divider || item is Header @@ -123,14 +123,14 @@ class ArtistDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtist(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateArtist) - collectImmediately(detailModel.artistList, ::updateList) + collectImmediately(detailModel.artistSongList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) - collect(musicModel.playlistDecision.flow, ::handleDecision) + collect(listModel.menu.flow, ::handleMenu) + collectImmediately(listModel.selected, ::updateSelection) + collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) - collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) + collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -139,63 +139,21 @@ class ArtistDetailFragment : binding.detailRecycler.adapter = null // 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.artistInstructions.consume() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - if (super.onMenuItemClick(item)) { - return true - } - - val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value) - return when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(currentArtist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(currentArtist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(currentArtist) - true - } - R.id.action_share -> { - requireContext().share(currentArtist) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } + detailModel.artistSongInstructions.consume() } override fun onRealClick(item: Music) { when (item) { is Album -> detailModel.showAlbum(item) - is Song -> { - val playbackMode = detailModel.playbackMode - if (playbackMode != null) { - playbackModel.playFrom(item, playbackMode) - } else { - // When configured to play from the selected item, we already have an Artist - // to play from. - playbackModel.playFromArtist( - item, unlikelyToBeNull(detailModel.currentArtist.value)) - } - } + is Song -> playbackModel.play(item, detailModel.playInArtistWith) else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Music, anchor: View) { + override fun onOpenMenu(item: Music) { when (item) { - is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item) - is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, 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}") } } @@ -208,33 +166,8 @@ class ArtistDetailFragment : playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value)) } - override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_artist_sort) { - // Select the corresponding sort mode option - val sort = detailModel.artistSongSort - unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true - // Select the corresponding sort direction option - val directionItemId = - when (sort.direction) { - Sort.Direction.ASCENDING -> R.id.option_sort_asc - Sort.Direction.DESCENDING -> R.id.option_sort_dec - } - unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true - setOnMenuItemClickListener { item -> - item.isChecked = !item.isChecked - - detailModel.artistSongSort = - when (item.itemId) { - // Sort direction options - R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) - R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) - // Any other option is a sort mode - else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) - } - - true - } - } + override fun onOpenSortMenu() { + findNavController().navigateSafe(ArtistDetailFragmentDirections.sort()) } private fun updateArtist(artist: Artist?) { @@ -243,24 +176,12 @@ class ArtistDetailFragment : findNavController().navigateUp() return } - requireBinding().detailNormalToolbar.apply { - title = artist.name.resolve(requireContext()) - - // Disable options that make no sense with an empty artist - val playable = artist.songs.isNotEmpty() - if (!playable) { - logD("Artist is empty, disabling playback/playlist/share options") - } - menu.findItem(R.id.action_play_next).isEnabled = playable - menu.findItem(R.id.action_queue_add).isEnabled = playable - menu.findItem(R.id.action_playlist_add).isEnabled = playable - menu.findItem(R.id.action_share).isEnabled = playable - } + requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext()) artistHeaderAdapter.setParent(artist) } private fun updateList(list: List) { - artistListAdapter.update(list, detailModel.artistInstructions.consume()) + artistListAdapter.update(list, detailModel.artistSongInstructions.consume()) } private fun handleShow(show: Show?) { @@ -300,8 +221,16 @@ class ArtistDetailFragment : .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) } } - is Show.SongArtistDetails, - is Show.AlbumArtistDetails, + 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") @@ -310,6 +239,21 @@ class ArtistDetailFragment : } } + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + when (menu) { + 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") + } + findNavController().navigateSafe(directions) + } + private fun updateSelection(selected: List) { artistListAdapter.setSelected(selected.toSet()) @@ -322,21 +266,20 @@ class ArtistDetailFragment : } } - private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Adding ${decision.songs.size} songs to a playlist") - findNavController() - .navigateSafe( - ArtistDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - musicModel.playlistDecision.consume() + private fun handlePlaylistDecision(decision: PlaylistDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} songs to a playlist") + ArtistDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } - is PlaylistDecision.New, - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + findNavController().navigateSafe(directions) } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { @@ -354,15 +297,17 @@ class ArtistDetailFragment : artistListAdapter.setPlaying(playingItem, isPlaying) } - private fun handlePlayFromArtist(song: Song?) { - if (song == null) return - logD("Launching play from artist dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) - } - - private fun handlePlayFromGenre(song: Song?) { - if (song == null) return - logD("Launching play from genre dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaybackDecision.PlayFromArtist -> + error("Unexpected playback decision $decision") + is PlaybackDecision.PlayFromGenre -> { + logD("Launching play from artist dialog for $decision") + ArtistDetailFragmentDirections.playFromGenre(decision.song.uid) + } + } + findNavController().navigateSafe(directions) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index 28c1f65f7..3c494cd96 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -59,7 +59,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr override fun onAttachedToWindow() { super.onAttachedToWindow() - (layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context) + if (!isInEditMode) { + (layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context) + } } private fun findTitleView(): TextView { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index de285ffa9..3613d96c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -36,24 +36,25 @@ import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.list.sort.Sort 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.MusicMode import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.AudioProperties +import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the @@ -65,12 +66,15 @@ import org.oxycblt.auxio.util.logW class DetailViewModel @Inject constructor( + private val listSettings: ListSettings, private val musicRepository: MusicRepository, private val audioPropertiesFactory: AudioProperties.Factory, - private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings ) : ViewModel(), MusicRepository.UpdateListener { private val _toShow = MutableEvent() + /** + * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. + */ val toShow: Event get() = _toShow @@ -94,23 +98,23 @@ constructor( val currentAlbum: StateFlow get() = _currentAlbum - private val _albumList = MutableStateFlow(listOf()) + private val _albumSongList = MutableStateFlow(listOf()) /** The current list data derived from [currentAlbum]. */ - val albumList: StateFlow> - get() = _albumList - private val _albumInstructions = MutableEvent() - /** Instructions for updating [albumList] in the UI. */ - val albumInstructions: Event - get() = _albumInstructions - - /** The current [Sort] used for [Song]s in [albumList]. */ - var albumSongSort: Sort - get() = musicSettings.albumSongSort - set(value) { - musicSettings.albumSongSort = value - // Refresh the album list to reflect the new sort. - currentAlbum.value?.let { refreshAlbumList(it, true) } - } + val albumSongList: StateFlow> + get() = _albumSongList + + private val _albumSongInstructions = MutableEvent() + /** Instructions for updating [albumSongList] in the UI. */ + val albumSongInstructions: Event + get() = _albumSongInstructions + + /** The current [Sort] used for [Song]s in [albumSongList]. */ + val albumSongSort: Sort + get() = listSettings.albumSongSort + + /** The [PlaySong] instructions to use when playing a [Song] from [Album] details. */ + val playInAlbumWith + get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromAlbum // --- ARTIST --- @@ -119,23 +123,28 @@ constructor( val currentArtist: StateFlow get() = _currentArtist - private val _artistList = MutableStateFlow(listOf()) + private val _artistSongList = MutableStateFlow(listOf()) /** The current list derived from [currentArtist]. */ - val artistList: StateFlow> = _artistList - private val _artistInstructions = MutableEvent() - /** Instructions for updating [artistList] in the UI. */ - val artistInstructions: Event - get() = _artistInstructions + val artistSongList: StateFlow> = _artistSongList + + private val _artistSongInstructions = MutableEvent() + /** Instructions for updating [artistSongList] in the UI. */ + val artistSongInstructions: Event + get() = _artistSongInstructions - /** The current [Sort] used for [Song]s in [artistList]. */ + /** The current [Sort] used for [Song]s in [artistSongList]. */ var artistSongSort: Sort - get() = musicSettings.artistSongSort + get() = listSettings.artistSongSort set(value) { - musicSettings.artistSongSort = value + listSettings.artistSongSort = value // Refresh the artist list to reflect the new sort. currentArtist.value?.let { refreshArtistList(it, true) } } + /** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */ + val playInArtistWith + get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromArtist(currentArtist.value) + // --- GENRE --- private val _currentGenre = MutableStateFlow(null) @@ -143,23 +152,28 @@ constructor( val currentGenre: StateFlow get() = _currentGenre - private val _genreList = MutableStateFlow(listOf()) + private val _genreSongList = MutableStateFlow(listOf()) /** The current list data derived from [currentGenre]. */ - val genreList: StateFlow> = _genreList - private val _genreInstructions = MutableEvent() - /** Instructions for updating [artistList] in the UI. */ - val genreInstructions: Event - get() = _genreInstructions + val genreSongList: StateFlow> = _genreSongList - /** The current [Sort] used for [Song]s in [genreList]. */ + private val _genreSongInstructions = MutableEvent() + /** Instructions for updating [artistSongList] in the UI. */ + val genreSongInstructions: Event + get() = _genreSongInstructions + + /** The current [Sort] used for [Song]s in [genreSongList]. */ var genreSongSort: Sort - get() = musicSettings.genreSongSort + get() = listSettings.genreSongSort set(value) { - musicSettings.genreSongSort = value + listSettings.genreSongSort = value // Refresh the genre list to reflect the new sort. currentGenre.value?.let { refreshGenreList(it, true) } } + /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */ + val playInGenreWith + get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromGenre(currentGenre.value) + // --- PLAYLIST --- private val _currentPlaylist = MutableStateFlow(null) @@ -167,13 +181,14 @@ constructor( val currentPlaylist: StateFlow get() = _currentPlaylist - private val _playlistList = MutableStateFlow(listOf()) + private val _playlistSongList = MutableStateFlow(listOf()) /** The current list data derived from [currentPlaylist] */ - val playlistList: StateFlow> = _playlistList - private val _playlistInstructions = MutableEvent() - /** Instructions for updating [playlistList] in the UI. */ - val playlistInstructions: Event - get() = _playlistInstructions + val playlistSongList: StateFlow> = _playlistSongList + + private val _playlistSongInstructions = MutableEvent() + /** Instructions for updating [playlistSongList] in the UI. */ + val playlistSongInstructions: Event + get() = _playlistSongInstructions private val _editedPlaylist = MutableStateFlow?>(null) /** @@ -183,12 +198,11 @@ constructor( val editedPlaylist: StateFlow?> get() = _editedPlaylist - /** - * The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently - * shown item. - */ - val playbackMode: MusicMode? - get() = playbackSettings.inParentPlaybackMode + /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */ + val playInPlaylistWith + get() = + playbackSettings.inParentPlaybackMode + ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) init { musicRepository.addUpdateListener(this) @@ -241,32 +255,74 @@ constructor( } } + /** + * Navigate to the details (properties) of a [Song]. + * + * @param song The [Song] to navigate with. + */ fun showSong(song: Song) = showImpl(Show.SongDetails(song)) + /** + * Navigate to the [Album] details of the given [Song], scrolling to the given [Song] as well. + * + * @param song The [Song] to navigate with. + */ fun showAlbum(song: Song) = showImpl(Show.SongAlbumDetails(song)) + /** + * Navigate to the details of an [Album]. + * + * @param album The [Album] to navigate with. + */ fun showAlbum(album: Album) = showImpl(Show.AlbumDetails(album)) + /** + * Navigate to the details of one of the [Artist]s of a [Song] using the corresponding choice + * dialog. If there is only one artist, this call is identical to [showArtist]. + * + * @param song The [Song] to navigate with. + */ fun showArtist(song: Song) = showImpl( if (song.artists.size > 1) { - Show.SongArtistDetails(song) + Show.SongArtistDecision(song) } else { Show.ArtistDetails(song.artists.first()) }) + /** + * Navigate to the details of one of the [Artist]s of an [Album] using the corresponding choice + * dialog. If there is only one artist, this call is identical to [showArtist]. + * + * @param album The [Album] to navigate with. + */ fun showArtist(album: Album) = showImpl( if (album.artists.size > 1) { - Show.AlbumArtistDetails(album) + Show.AlbumArtistDecision(album) } else { Show.ArtistDetails(album.artists.first()) }) + /** + * Navigate to the details of an [Artist]. + * + * @param artist The [Artist] to navigate with. + */ fun showArtist(artist: Artist) = showImpl(Show.ArtistDetails(artist)) + /** + * Navigate to the details of a [Genre]. + * + * @param genre The [Genre] to navigate with. + */ fun showGenre(genre: Genre) = showImpl(Show.GenreDetails(genre)) + /** + * Navigate to the details of a [Playlist]. + * + * @param playlist The [Playlist] to navigate with. + */ fun showPlaylist(playlist: Playlist) = showImpl(Show.PlaylistDetails(playlist)) private fun showImpl(show: Show) { @@ -293,7 +349,7 @@ constructor( } /** - * Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumList] will be + * Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumSongList] will be * updated to align with the new [Album]. * * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid. @@ -308,7 +364,17 @@ constructor( } /** - * Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistList] will be + * Apply a new [Sort] to [albumSongList]. + * + * @param sort The [Sort] to apply. + */ + fun applyAlbumSongSort(sort: Sort) { + listSettings.albumSongSort = sort + _currentAlbum.value?.let { refreshAlbumList(it, true) } + } + + /** + * Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistSongList] will be * updated to align with the new [Artist]. * * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid. @@ -323,7 +389,17 @@ constructor( } /** - * Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreList] will be + * Apply a new [Sort] to [artistSongList]. + * + * @param sort The [Sort] to apply. + */ + fun applyArtistSongSort(sort: Sort) { + listSettings.artistSongSort = sort + _currentArtist.value?.let { refreshArtistList(it, true) } + } + + /** + * Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreSongList] will be * updated to align with the new album. * * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid. @@ -337,6 +413,16 @@ constructor( } } + /** + * Apply a new [Sort] to [genreSongList]. + * + * @param sort The [Sort] to apply. + */ + fun applyGenreSongSort(sort: Sort) { + listSettings.genreSongSort = sort + _currentGenre.value?.let { refreshGenreList(it, true) } + } + /** * Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs, * [currentPlaylist] and [currentPlaylist] will be updated to align with the new album. @@ -394,6 +480,17 @@ constructor( return true } + /** + * Apply a [Sort] to the edited playlist. Does nothing if not in an editing session. + * + * @param sort The [Sort] to apply. + */ + fun applyPlaylistSongSort(sort: Sort) { + val playlist = _currentPlaylist.value ?: return + _editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return) + refreshPlaylistList(playlist, UpdateInstructions.Replace(2)) + } + /** * (Visually) move a song in the current playlist. Does nothing if not in an editing session. * @@ -402,7 +499,6 @@ constructor( * @return true if the song was moved, false otherwise. */ fun movePlaylistSongs(from: Int, to: Int): Boolean { - // TODO: Song re-sorting val playlist = _currentPlaylist.value ?: return false val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() val realFrom = from - 2 @@ -486,8 +582,8 @@ constructor( } logD("Update album list to ${list.size} items with $instructions") - _albumInstructions.put(instructions) - _albumList.value = list + _albumSongInstructions.put(instructions) + _albumSongList.value = list } private fun refreshArtistList(artist: Artist, replace: Boolean = false) { @@ -511,6 +607,7 @@ constructor( is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS is ReleaseType.Mix -> AlbumGrouping.DJMIXES is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES + is ReleaseType.Demo -> AlbumGrouping.DEMOS } } } @@ -549,8 +646,8 @@ constructor( } logD("Updating artist list to ${list.size} items with $instructions") - _artistInstructions.put(instructions) - _artistList.value = list.toList() + _artistSongInstructions.put(instructions) + _artistSongList.value = list.toList() } private fun refreshGenreList(genre: Genre, replace: Boolean = false) { @@ -575,8 +672,8 @@ constructor( list.addAll(genreSongSort.songs(genre.songs)) logD("Updating genre list to ${list.size} items with $instructions") - _genreInstructions.put(instructions) - _genreList.value = list + _genreSongInstructions.put(instructions) + _genreSongList.value = list } private fun refreshPlaylistList( @@ -595,8 +692,8 @@ constructor( } logD("Updating playlist list to ${list.size} items with $instructions") - _playlistInstructions.put(instructions) - _playlistList.value = list + _playlistSongInstructions.put(instructions) + _playlistSongList.value = list } /** @@ -613,6 +710,7 @@ constructor( SOUNDTRACKS(R.string.lbl_soundtracks), DJMIXES(R.string.lbl_mixes), MIXTAPES(R.string.lbl_mixtapes), + DEMOS(R.string.lbl_demos), APPEARANCES(R.string.lbl_appears_on), LIVE(R.string.lbl_live_group), REMIXES(R.string.lbl_remix_group), @@ -624,13 +722,68 @@ constructor( } } +/** + * A command for navigation to detail views. These can be handled partially if a certain command + * cannot occur in a specific view. + * + * @author Alexander Capehart (OxygenCobalt) + */ sealed interface Show { + /** + * Navigate to the details (properties) of a [Song]. + * + * @param song The [Song] to navigate with. + */ data class SongDetails(val song: Song) : Show + + /** + * Navigate to the details of an [Album]. + * + * @param album The [Album] to navigate with. + */ data class AlbumDetails(val album: Album) : Show + + /** + * Navigate to the [Album] details of the given [Song], scrolling to the given [Song] as well. + * + * @param song The [Song] to navigate with. + */ data class SongAlbumDetails(val song: Song) : Show + + /** + * Navigate to the details of an [Artist]. + * + * @param artist The [Artist] to navigate with. + */ data class ArtistDetails(val artist: Artist) : Show - data class SongArtistDetails(val song: Song) : Show - data class AlbumArtistDetails(val album: Album) : Show + + /** + * Navigate to the details of one of the [Artist]s of a [Song] using the corresponding choice + * dialog. + * + * @param song The [Song] to navigate with. + */ + data class SongArtistDecision(val song: Song) : Show + + /** + * Navigate to the details of one of the [Artist]s of an [Album] using the corresponding + * decision dialog. + * + * @param album The [Album] to navigate with. + */ + data class AlbumArtistDecision(val album: Album) : Show + + /** + * Navigate to the details of a [Genre]. + * + * @param genre The [Genre] to navigate with. + */ data class GenreDetails(val genre: Genre) : Show + + /** + * Navigate to the details of a [Playlist]. + * + * @param playlist The [Playlist] to navigate with. + */ data class PlaylistDetails(val playlist: Playlist) : Show } 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 a2d2e2cd9..522ebbfa6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -39,8 +37,8 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music @@ -48,15 +46,14 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel 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.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup -import org.oxycblt.auxio.util.share -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -69,8 +66,8 @@ class GenreDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, DetailListAdapter.Listener { - override val detailModel: DetailViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what genre to display is initially within the navigation arguments @@ -97,9 +94,12 @@ class GenreDetailFragment : // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_parent_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@GenreDetailFragment) + overrideOnOverflowMenuClick { + listModel.openMenu( + R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value)) + } } binding.detailRecycler.apply { @@ -107,7 +107,7 @@ class GenreDetailFragment : (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { val item = - detailModel.genreList.value.getOrElse(it - 1) { + detailModel.genreSongList.value.getOrElse(it - 1) { return@setFullWidthLookup false } item is Divider || item is Header @@ -121,14 +121,14 @@ class GenreDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenre(args.genreUid) collectImmediately(detailModel.currentGenre, ::updatePlaylist) - collectImmediately(detailModel.genreList, ::updateList) + collectImmediately(detailModel.genreSongList, ::updateList) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) + collect(listModel.menu.flow, ::handleMenu) + collectImmediately(listModel.selected, ::updateSelection) collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) - collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) + collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -137,63 +137,21 @@ class GenreDetailFragment : binding.detailRecycler.adapter = null // 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.genreInstructions.consume() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - if (super.onMenuItemClick(item)) { - return true - } - - val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value) - return when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(currentGenre) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(currentGenre) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(currentGenre) - true - } - R.id.action_share -> { - requireContext().share(currentGenre) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } + detailModel.genreSongInstructions.consume() } override fun onRealClick(item: Music) { when (item) { is Artist -> detailModel.showArtist(item) - is Song -> { - val playbackMode = detailModel.playbackMode - if (playbackMode != null) { - playbackModel.playFrom(item, playbackMode) - } else { - // When configured to play from the selected item, we already have an Genre - // to play from. - playbackModel.playFromGenre( - item, unlikelyToBeNull(detailModel.currentGenre.value)) - } - } + is Song -> playbackModel.play(item, detailModel.playInGenreWith) else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Music, anchor: View) { + override fun onOpenMenu(item: Music) { when (item) { - is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) + 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}") } } @@ -206,31 +164,8 @@ class GenreDetailFragment : playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value)) } - override fun onOpenSortMenu(anchor: View) { - openMenu(anchor, R.menu.menu_genre_sort) { - // Select the corresponding sort mode option - val sort = detailModel.genreSongSort - unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true - // Select the corresponding sort direction option - val directionItemId = - when (sort.direction) { - Sort.Direction.ASCENDING -> R.id.option_sort_asc - Sort.Direction.DESCENDING -> R.id.option_sort_dec - } - unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true - setOnMenuItemClickListener { item -> - item.isChecked = !item.isChecked - detailModel.genreSongSort = - when (item.itemId) { - // Sort direction options - R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING) - R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING) - // Any other option is a sort mode - else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) - } - true - } - } + override fun onOpenSortMenu() { + findNavController().navigateSafe(GenreDetailFragmentDirections.sort()) } private fun updatePlaylist(genre: Genre?) { @@ -244,7 +179,7 @@ class GenreDetailFragment : } private fun updateList(list: List) { - genreListAdapter.update(list, detailModel.genreInstructions.consume()) + genreListAdapter.update(list, detailModel.genreSongInstructions.consume()) } private fun handleShow(show: Show?) { @@ -277,15 +212,15 @@ class GenreDetailFragment : findNavController() .navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid)) } - is Show.SongArtistDetails -> { + is Show.SongArtistDecision -> { logD("Navigating to artist choices for ${show.song}") findNavController() - .navigateSafe(GenreDetailFragmentDirections.showArtist(show.song.uid)) + .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid)) } - is Show.AlbumArtistDetails -> { + is Show.AlbumArtistDecision -> { logD("Navigating to artist choices for ${show.album}") findNavController() - .navigateSafe(GenreDetailFragmentDirections.showArtist(show.album.uid)) + .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid)) } is Show.GenreDetails -> { logD("Navigated to this genre") @@ -298,6 +233,20 @@ class GenreDetailFragment : } } + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + when (menu) { + 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") + } + findNavController().navigateSafe(directions) + } + private fun updateSelection(selected: List) { genreListAdapter.setSelected(selected.toSet()) @@ -311,20 +260,19 @@ class GenreDetailFragment : } private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Adding ${decision.songs.size} songs to a playlist") - findNavController() - .navigateSafe( - GenreDetailFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - musicModel.playlistDecision.consume() + if (decision == null) return + val directions = + when (decision) { + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} songs to a playlist") + GenreDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.New, + is PlaylistDecision.Rename, + is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision") } - is PlaylistDecision.New, - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + findNavController().navigateSafe(directions) } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { @@ -342,15 +290,16 @@ class GenreDetailFragment : genreListAdapter.setPlaying(playingItem, isPlaying) } - private fun handlePlayFromArtist(song: Song?) { - if (song == null) return - logD("Launching play from artist dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) - } - - private fun handlePlayFromGenre(song: Song?) { - if (song == null) return - logD("Launching play from genre dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaybackDecision.PlayFromArtist -> { + logD("Launching play from artist dialog for $decision") + GenreDetailFragmentDirections.playFromArtist(decision.song.uid) + } + is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision") + } + findNavController().navigateSafe(directions) } } 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 dc13ef831..540017724 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -21,10 +21,7 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem -import android.view.View 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 @@ -43,22 +40,23 @@ import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist 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 -import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.setFullWidthLookup -import org.oxycblt.auxio.util.share -import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -70,10 +68,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class PlaylistDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, - PlaylistDetailListAdapter.Listener, - NavController.OnDestinationChangedListener { - override val detailModel: DetailViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + PlaylistDetailListAdapter.Listener { + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() // Information about what playlist to display is initially within the navigation arguments @@ -82,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) @@ -100,11 +97,16 @@ class PlaylistDetailFragment : override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) + editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) + // --- UI SETUP --- binding.detailNormalToolbar.apply { - inflateMenu(R.menu.menu_playlist_detail) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(this@PlaylistDetailFragment) + overrideOnOverflowMenuClick { + listModel.openMenu( + R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value)) + } } binding.detailEditToolbar.apply { @@ -121,7 +123,7 @@ class PlaylistDetailFragment : (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { val item = - detailModel.playlistList.value.getOrElse(it - 1) { + detailModel.playlistSongList.value.getOrElse(it - 1) { return@setFullWidthLookup false } item is Divider || item is Header @@ -135,28 +137,42 @@ class PlaylistDetailFragment : // DetailViewModel handles most initialization from the navigation argument. detailModel.setPlaylist(args.playlistUid) collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) - collectImmediately(detailModel.playlistList, ::updateList) + collectImmediately(detailModel.playlistSongList, ::updateList) collectImmediately(detailModel.editedPlaylist, ::updateEditedList) collect(detailModel.toShow.flow, ::handleShow) - collectImmediately(selectionModel.selected, ::updateSelection) + collect(listModel.menu.flow, ::handleMenu) + collectImmediately(listModel.selected, ::updateSelection) collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) - collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist) - collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre) + 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) { @@ -166,76 +182,20 @@ class PlaylistDetailFragment : binding.detailRecycler.adapter = null // 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.playlistInstructions.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 - } - // Drop any pending playlist edits when navigating away. This could actually happen - // if the user is quick enough. - detailModel.dropPlaylistEdit() - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - if (super.onMenuItemClick(item)) { - return true - } - - val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value) - return when (item.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(currentPlaylist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(currentPlaylist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_rename -> { - musicModel.renamePlaylist(currentPlaylist) - true - } - R.id.action_delete -> { - musicModel.deletePlaylist(currentPlaylist) - true - } - R.id.action_share -> { - requireContext().share(currentPlaylist) - true - } - R.id.action_save -> { - detailModel.savePlaylistEdit() - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } + detailModel.playlistSongInstructions.consume() + editNavigationListener = null } override fun onRealClick(item: Song) { - playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value)) + playbackModel.play(item, detailModel.playInPlaylistWith) } override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder) } - override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item) + override fun onOpenMenu(item: Song) { + listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith) } override fun onPlay() { @@ -250,7 +210,9 @@ class PlaylistDetailFragment : detailModel.startPlaylistEdit() } - override fun onOpenSortMenu(anchor: View) {} + override fun onOpenSortMenu() { + findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort()) + } private fun updatePlaylist(playlist: Playlist?) { if (playlist == null) { @@ -259,30 +221,20 @@ class PlaylistDetailFragment : return } val binding = requireBinding() - binding.detailNormalToolbar.apply { - title = playlist.name.resolve(requireContext()) - // Disable options that make no sense with an empty playlist - val playable = playlist.songs.isNotEmpty() - if (!playable) { - logD("Playlist is empty, disabling playback/share options") - } - menu.findItem(R.id.action_play_next).isEnabled = playable - menu.findItem(R.id.action_queue_add).isEnabled = playable - menu.findItem(R.id.action_share).isEnabled = playable - } + binding.detailNormalToolbar.title = playlist.name.resolve(requireContext()) binding.detailEditToolbar.title = getString(R.string.fmt_editing, playlist.name.resolve(requireContext())) playlistHeaderAdapter.setParent(playlist) } private fun updateList(list: List) { - playlistListAdapter.update(list, detailModel.playlistInstructions.consume()) + playlistListAdapter.update(list, detailModel.playlistSongInstructions.consume()) } private fun updateEditedList(editedPlaylist: List?) { playlistListAdapter.setEditing(editedPlaylist != null) playlistHeaderAdapter.setEditedPlaylist(editedPlaylist) - selectionModel.drop() + listModel.dropSelection() if (editedPlaylist != null) { logD("Updating save button state") @@ -301,38 +253,31 @@ class PlaylistDetailFragment : findNavController() .navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid)) } - - // Songs should be scrolled to if the album matches, or a new detail - // fragment should be launched otherwise. is Show.SongAlbumDetails -> { logD("Navigating to the album of ${show.song}") findNavController() .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid)) } - - // If the album matches, no need to do anything. Otherwise launch a new - // detail fragment. is Show.AlbumDetails -> { logD("Navigating to ${show.album}") findNavController() .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid)) } - - // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") findNavController() .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid)) } - is Show.SongArtistDetails -> { + is Show.SongArtistDecision -> { logD("Navigating to artist choices for ${show.song}") findNavController() - .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.song.uid)) + .navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid)) } - is Show.AlbumArtistDetails -> { + is Show.AlbumArtistDecision -> { logD("Navigating to artist choices for ${show.album}") findNavController() - .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.album.uid)) + .navigateSafe( + PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid)) } is Show.PlaylistDetails -> { logD("Navigated to this playlist") @@ -345,6 +290,22 @@ class PlaylistDetailFragment : } } + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + when (menu) { + 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") + } + findNavController().navigateSafe(directions) + } + private fun updateSelection(selected: List) { playlistListAdapter.setSelected(selected.toSet()) @@ -357,23 +318,24 @@ class PlaylistDetailFragment : private fun handleDecision(decision: PlaylistDecision?) { if (decision == null) return - when (decision) { - is PlaylistDecision.Rename -> { - logD("Renaming ${decision.playlist}") - findNavController() - .navigateSafe( - PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)) - } - is PlaylistDecision.Delete -> { - logD("Deleting ${decision.playlist}") - findNavController() - .navigateSafe( - PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)) + val directions = + when (decision) { + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} songs to a playlist") + PlaylistDetailFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.New -> error("Unexpected playlist decision $decision") } - is PlaylistDecision.Add, - is PlaylistDecision.New -> error("Unexpected decision $decision") - } - musicModel.playlistDecision.consume() + findNavController().navigateSafe(directions) } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { @@ -382,17 +344,22 @@ class PlaylistDetailFragment : song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying) } - private fun handlePlayFromArtist(song: Song?) { - if (song == null) return - logD("Launching play from artist dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid)) + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaybackDecision.PlayFromArtist -> { + logD("Launching play from artist dialog for $decision") + PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid) + } + is PlaybackDecision.PlayFromGenre -> { + logD("Launching play from artist dialog for $decision") + PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid) + } + } + findNavController().navigateSafe(directions) } - private fun handlePlayFromGenre(song: Song?) { - if (song == null) return - logD("Launching play from genre dialog for $song") - findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid)) - } private fun updateMultiToolbar() { val id = when { @@ -400,7 +367,7 @@ class PlaylistDetailFragment : logD("Currently editing playlist, showing edit toolbar") R.id.detail_edit_toolbar } - selectionModel.selected.value.isNotEmpty() -> { + listModel.selected.value.isNotEmpty() -> { logD("Currently selecting, showing selection toolbar") R.id.detail_selection_toolbar } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index f7b293a06..f43da103c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -38,18 +38,18 @@ import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.formatDurationMs -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that shows information about a Song. + * A [ViewBindingMaterialDialogFragment] that shows information about a Song. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class SongDetailDialog : ViewBindingDialogFragment() { +class SongDetailDialog : ViewBindingMaterialDialogFragment() { private val detailModel: DetailViewModel by activityViewModels() // Information about what song to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an song. @@ -69,8 +69,8 @@ class SongDetailDialog : ViewBindingDialogFragment() { binding.detailProperties.adapter = detailAdapter // DetailViewModel handles most initialization from the navigation argument. detailModel.setSong(args.songUid) + detailModel.toShow.consume() collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong) - collectImmediately(detailModel.toShow.flow, ::handleShow) } private fun updateSong(song: Song?, info: AudioProperties?) { @@ -126,16 +126,6 @@ class SongDetailDialog : ViewBindingDialogFragment() { } } - private fun handleShow(show: Show?) { - if (show == null) return - if (show is Show.SongDetails) { - logD("Navigated to this song") - detailModel.toShow.consume() - } else { - error("Unexpected show command $show") - } - } - private fun T.zipName(context: Context): String { val name = name return if (name is Name.Known && name.sort != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt rename to app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt index f28b9d765..98a411a04 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/picker/ArtistShowChoice.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.detail.picker +package org.oxycblt.auxio.detail.decision import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/DetailDecisionViewModel.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/detail/decision/DetailDecisionViewModel.kt index a510b6e4a..efe219235 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/picker/NavigationPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/decision/DetailDecisionViewModel.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * NavigationPickerViewModel.kt is part of Auxio. + * DetailDecisionViewModel.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.detail.picker +package org.oxycblt.auxio.detail.decision import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,12 +33,13 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW /** - * A [ViewModel] that stores the current information required for navigation picker dialogs + * A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the + * future. * * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : +class DetailPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : ViewModel(), MusicRepository.UpdateListener { private val _artistChoices = MutableStateFlow(null) /** The current set of [Artist] choices to show in the picker, or null if to show nothing. */ @@ -49,6 +50,11 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: musicRepository.addUpdateListener(this) } + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + override fun onMusicChanges(changes: MusicRepository.Changes) { if (!changes.deviceLibrary) return val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -57,11 +63,6 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository: logD("Updated artist choices: ${_artistChoices.value}") } - override fun onCleared() { - super.onCleared() - musicRepository.removeUpdateListener(this) - } - /** * Set the [Music.UID] of the item to show artist choices for. * @@ -105,16 +106,16 @@ sealed interface ArtistShowChoices { class FromSong(val song: Song) : ArtistShowChoices { override val uid = song.uid override val choices = song.artists + override fun sanitize(newLibrary: DeviceLibrary) = newLibrary.findSong(uid)?.let { FromSong(it) } } - /** - * Backing implementation of [ArtistShowChoices] that is based on an [AlbumArtistShowChoices]. - */ + /** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */ data class FromAlbum(val album: Album) : ArtistShowChoices { override val uid = album.uid override val choices = album.artists + override fun sanitize(newLibrary: DeviceLibrary) = newLibrary.findAlbum(uid)?.let { FromAlbum(it) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt similarity index 78% rename from app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt index a98dfb162..1f82ddfe2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/picker/ShowArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.detail.picker +package org.oxycblt.auxio.detail.decision import android.os.Bundle import android.view.LayoutInflater @@ -33,19 +33,20 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD /** - * A picker [ViewBindingDialogFragment] intended for when the [Artist] to show is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class ShowArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val detailModel: DetailViewModel by activityViewModels() - private val pickerModel: NavigationPickerViewModel by viewModels() + private val pickerModel: DetailPickerViewModel by viewModels() // Information about what artists to show choices for is initially within the navigation // arguments as UIDs, as that is the only safe way to parcel an artist. private val args: ShowArtistDialogArgs by navArgs() @@ -66,14 +67,9 @@ class ShowArtistDialog : adapter = choiceAdapter } + detailModel.toShow.consume() pickerModel.setArtistChoiceUid(args.itemUid) - collectImmediately(pickerModel.artistChoices) { - if (it != null) { - choiceAdapter.update(it.choices, UpdateInstructions.Replace(0)) - } else { - findNavController().navigateUp() - } - } + collectImmediately(pickerModel.artistChoices, ::updateChoices) } override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { @@ -82,8 +78,17 @@ class ShowArtistDialog : } override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { + findNavController().navigateUp() // User made a choice, navigate to the artist. detailModel.showArtist(item) - findNavController().navigateUp() + } + + private fun updateChoices(choices: ArtistShowChoices?) { + if (choices == null) { + logD("No choices to show, navigating away") + findNavController().navigateUp() + return + } + choiceAdapter.update(choices.choices, UpdateInstructions.Diff) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt index 02303a566..e85c892e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/ArtistDetailHeaderAdapter.kt @@ -41,6 +41,7 @@ class ArtistDetailHeaderAdapter(private val listener: Listener) : DetailHeaderAdapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ArtistDetailHeaderViewHolder.from(parent) + override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) = holder.bind(parent, listener) } @@ -70,7 +71,11 @@ private constructor(private val binding: ItemDetailHeaderBinding) : binding.detailInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + if (artist.explicitAlbums.isNotEmpty()) { + binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size) + } else { + binding.context.getString(R.string.def_album_count) + }, if (artist.songs.isNotEmpty()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt index 247875432..4afabb6c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/header/DetailHeaderAdapter.kt @@ -30,7 +30,9 @@ import org.oxycblt.auxio.util.logD abstract class DetailHeaderAdapter : RecyclerView.Adapter() { private var currentParent: T? = null + final override fun getItemCount() = 1 + final override fun onBindViewHolder(holder: VH, position: Int) = onBindHeader(holder, requireNotNull(currentParent)) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt index ba350e7b3..08293199f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt @@ -82,7 +82,7 @@ abstract class DetailListAdapter( * Called when the button in a [SortHeader] item is pressed, requesting that the sort menu * should be opened. */ - fun onOpenSortMenu(anchor: View) + fun onOpenSortMenu() } protected companion object { @@ -132,7 +132,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : // Add a Tooltip based on the content description so that the purpose of this // button can be clear. TooltipCompat.setTooltipText(this, contentDescription) - setOnClickListener(listener::onOpenSortMenu) + setOnClickListener { listener.onOpenSortMenu() } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index 06c5be29b..ca8a0657b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -170,10 +170,25 @@ private class EditHeaderViewHolder private constructor(private val binding: Item TooltipCompat.setTooltipText(this, contentDescription) setOnClickListener { listener.onStartEdit() } } + binding.headerSort.apply { + TooltipCompat.setTooltipText(this, contentDescription) + setOnClickListener { listener.onOpenSortMenu() } + } } override fun updateEditing(editing: Boolean) { - binding.headerEdit.isEnabled = !editing + binding.headerEdit.apply { + isVisible = !editing + isClickable = !editing + isFocusable = !editing + jumpDrawablesToCurrentState() + } + binding.headerSort.apply { + isVisible = editing + isClickable = editing + isFocusable = editing + jumpDrawablesToCurrentState() + } } companion object { @@ -211,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) : PlaylistDetailListAdapter.ViewHolder { override val enabled: Boolean get() = binding.songDragHandle.isVisible + override val root = binding.root override val body = binding.body override val delete = binding.background diff --git a/app/src/main/java/org/oxycblt/auxio/detail/sort/AlbumSongSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/sort/AlbumSongSortDialog.kt new file mode 100644 index 000000000..de8fe127d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/sort/AlbumSongSortDialog.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 Auxio Project + * AlbumSongSortDialog.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.detail.sort + +import android.os.Bundle +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.databinding.DialogSortBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD + +/** + * A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class AlbumSongSortDialog : SortDialog() { + private val detailModel: DetailViewModel by activityViewModels() + + override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- VIEWMODEL SETUP --- + collectImmediately(detailModel.currentAlbum, ::updateAlbum) + } + + override fun getInitialSort() = detailModel.albumSongSort + + override fun applyChosenSort(sort: Sort) { + detailModel.applyAlbumSongSort(sort) + } + + override fun getModeChoices() = listOf(Sort.Mode.ByDisc, Sort.Mode.ByTrack) + + private fun updateAlbum(album: Album?) { + if (album == null) { + logD("No album to sort, navigating away") + findNavController().navigateUp() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/sort/ArtistSongSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/sort/ArtistSongSortDialog.kt new file mode 100644 index 000000000..d0d4e355a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/sort/ArtistSongSortDialog.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 Auxio Project + * ArtistSongSortDialog.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.detail.sort + +import android.os.Bundle +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.databinding.DialogSortBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD + +/** + * A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class ArtistSongSortDialog : SortDialog() { + private val detailModel: DetailViewModel by activityViewModels() + + override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- VIEWMODEL SETUP --- + collectImmediately(detailModel.currentArtist, ::updateArtist) + } + + override fun getInitialSort() = detailModel.artistSongSort + + override fun applyChosenSort(sort: Sort) { + detailModel.applyArtistSongSort(sort) + } + + override fun getModeChoices() = + listOf(Sort.Mode.ByName, Sort.Mode.ByAlbum, Sort.Mode.ByDate, Sort.Mode.ByDuration) + + private fun updateArtist(artist: Artist?) { + if (artist == null) { + logD("No artist to sort, navigating away") + findNavController().navigateUp() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/sort/GenreSongSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/sort/GenreSongSortDialog.kt new file mode 100644 index 000000000..88c69172b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/sort/GenreSongSortDialog.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 Auxio Project + * GenreSongSortDialog.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.detail.sort + +import android.os.Bundle +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.databinding.DialogSortBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD + +/** + * A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class GenreSongSortDialog : SortDialog() { + private val detailModel: DetailViewModel by activityViewModels() + + override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- VIEWMODEL SETUP --- + collectImmediately(detailModel.currentGenre, ::updateGenre) + } + + override fun getInitialSort() = detailModel.genreSongSort + + override fun applyChosenSort(sort: Sort) { + detailModel.applyGenreSongSort(sort) + } + + override fun getModeChoices() = + listOf( + Sort.Mode.ByName, + Sort.Mode.ByArtist, + Sort.Mode.ByAlbum, + Sort.Mode.ByDate, + Sort.Mode.ByDuration) + + private fun updateGenre(genre: Genre?) { + if (genre == null) { + logD("No genre to sort, navigating away") + findNavController().navigateUp() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/sort/PlaylistSongSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/sort/PlaylistSongSortDialog.kt new file mode 100644 index 000000000..923d41829 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/sort/PlaylistSongSortDialog.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistSongSortDialog.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.detail.sort + +import android.os.Bundle +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.databinding.DialogSortBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD + +/** + * A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistSongSortDialog : SortDialog() { + private val detailModel: DetailViewModel by activityViewModels() + + override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- VIEWMODEL SETUP --- + collectImmediately(detailModel.currentPlaylist, ::updatePlaylist) + } + + override fun getInitialSort() = null + + override fun applyChosenSort(sort: Sort) { + detailModel.applyPlaylistSongSort(sort) + } + + override fun getModeChoices() = + listOf( + Sort.Mode.ByName, + Sort.Mode.ByArtist, + Sort.Mode.ByAlbum, + Sort.Mode.ByDate, + Sort.Mode.ByDuration) + + private fun updatePlaylist(genre: Playlist?) { + if (genre == null) { + logD("No genre to sort, navigating away") + findNavController().navigateUp() + } + } +} 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 7aa4dbe5f..01558611d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -26,7 +26,6 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.MenuCompat import androidx.core.view.isVisible -import androidx.core.view.iterator import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -54,13 +53,13 @@ import org.oxycblt.auxio.home.list.PlaylistListFragment import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.list.selection.SelectionFragment -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.SelectionFragment +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.NoAudioPermissionException import org.oxycblt.auxio.music.NoMusicException @@ -75,7 +74,6 @@ import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.navigateSafe -import org.oxycblt.auxio.util.unlikelyToBeNull /** * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation @@ -86,7 +84,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull @AndroidEntryPoint class HomeFragment : SelectionFragment(), AppBarLayout.OnOffsetChangedListener { - override val selectionModel: SelectionViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() @@ -100,9 +98,9 @@ class HomeFragment : // Orientation change will wipe whatever transition we were using prior, which will // result in no transition when the user navigates back. Make sure we re-initialize // our transitions. - val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1) + val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1) if (axis > -1) { - setupAxisTransitions(axis) + applyAxisTransition(axis) } } } @@ -170,18 +168,19 @@ class HomeFragment : // --- VIEWMODEL SETUP --- collect(homeModel.recreateTabs.flow, ::handleRecreate) - collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) - collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.currentTabType, ::updateCurrentTab) + collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab) + collect(listModel.menu.flow, ::handleMenu) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(musicModel.playlistDecision.flow, ::handleDecision) collect(detailModel.toShow.flow, ::handleShow) } override fun onSaveInstanceState(outState: Bundle) { - val enter = enterTransition - if (enter is MaterialSharedAxis) { - outState.putInt(KEY_LAST_TRANSITION_AXIS, enter.axis) + val transition = enterTransition + if (transition is MaterialSharedAxis) { + outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis) } super.onSaveInstanceState(outState) @@ -214,67 +213,48 @@ class HomeFragment : // Handle main actions (Search, Settings, About) R.id.action_search -> { logD("Navigating to search") - setupAxisTransitions(MaterialSharedAxis.Z) + applyAxisTransition(MaterialSharedAxis.Z) findNavController().navigateSafe(HomeFragmentDirections.search()) true } R.id.action_settings -> { logD("Navigating to preferences") - findNavController().navigateSafe(HomeFragmentDirections.preferences()) + homeModel.showSettings() true } R.id.action_about -> { logD("Navigating to about") - findNavController().navigateSafe(HomeFragmentDirections.about()) + homeModel.showAbout() true } // Handle sort menu - R.id.submenu_sorting -> { + R.id.action_sort -> { // Junk click event when opening the menu - true - } - R.id.option_sort_asc -> { - logD("Switching to ascending sorting") - item.isChecked = true - homeModel.setSortForCurrentTab( - homeModel - .getSortForTab(homeModel.currentTabMode.value) - .withDirection(Sort.Direction.ASCENDING)) - true - } - R.id.option_sort_dec -> { - logD("Switching to descending sorting") - item.isChecked = true - homeModel.setSortForCurrentTab( - homeModel - .getSortForTab(homeModel.currentTabMode.value) - .withDirection(Sort.Direction.DESCENDING)) + val directions = + when (homeModel.currentTabType.value) { + MusicType.SONGS -> HomeFragmentDirections.sortSongs() + MusicType.ALBUMS -> HomeFragmentDirections.sortAlbums() + MusicType.ARTISTS -> HomeFragmentDirections.sortArtists() + MusicType.GENRES -> HomeFragmentDirections.sortGenres() + MusicType.PLAYLISTS -> HomeFragmentDirections.sortPlaylists() + } + findNavController().navigateSafe(directions) true } else -> { - val newMode = Sort.Mode.fromItemId(item.itemId) - if (newMode != null) { - // Sorting option was selected, mark it as selected and update the mode - logD("Updating sort mode") - item.isChecked = true - homeModel.setSortForCurrentTab( - homeModel.getSortForTab(homeModel.currentTabMode.value).withMode(newMode)) - true - } else { - logW("Unexpected menu item selected") - false - } + logW("Unexpected menu item selected") + false } } } private fun setupPager(binding: FragmentHomeBinding) { binding.homePager.adapter = - HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner) + HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams - if (homeModel.currentTabModes.size == 1) { + if (homeModel.currentTabTypes.size == 1) { // A single tab makes the tab layout redundant, hide it and disable the collapsing // behavior. logD("Single tab shown, disabling TabLayout") @@ -292,81 +272,26 @@ class HomeFragment : TabLayoutMediator( binding.homeTabs, binding.homePager, - AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes)) + AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes)) .attach() } - private fun updateCurrentTab(tabMode: MusicMode) { + private fun updateCurrentTab(tabType: MusicType) { val binding = requireBinding() - // Update the sort options to align with those allowed by the tab - val isVisible: (Int) -> Boolean = - when (tabMode) { - // Disallow sorting by count for songs - MusicMode.SONGS -> { - logD("Using song-specific menu options") - ({ id -> id != R.id.option_sort_count }) - } - // Disallow sorting by album for albums - MusicMode.ALBUMS -> { - logD("Using album-specific menu options") - ({ id -> id != R.id.option_sort_album }) - } - // Only allow sorting by name, count, and duration for parents - else -> { - logD("Using parent-specific menu options") - ({ id -> - id == R.id.option_sort_asc || - id == R.id.option_sort_dec || - id == R.id.option_sort_name || - id == R.id.option_sort_count || - id == R.id.option_sort_duration - }) - } - } - - val sortMenu = - unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu) - val toHighlight = homeModel.getSortForTab(tabMode) - - for (option in sortMenu) { - val isCurrentMode = option.itemId == toHighlight.mode.itemId - val isCurrentlyAscending = - option.itemId == R.id.option_sort_asc && - toHighlight.direction == Sort.Direction.ASCENDING - val isCurrentlyDescending = - option.itemId == R.id.option_sort_dec && - toHighlight.direction == Sort.Direction.DESCENDING - // Check the corresponding direction and mode sort options to align with - // the current sort of the tab. - if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) { - logD( - "Checking $option option [mode: $isCurrentMode asc: $isCurrentlyAscending dec: $isCurrentlyDescending]") - // Note: We cannot inline this boolean assignment since it unchecks all other radio - // buttons (even when setting it to false), which would result in nothing being - // selected. - option.isChecked = true - } - - // Disable options that are not allowed by the isVisible lambda - option.isVisible = isVisible(option.itemId) - if (!option.isVisible) { - logD("Hiding $option option") - } - } // Update the scrolling view in AppBarLayout to align with the current tab's // scrolling state. This prevents the lift state from being confused as one // goes between different tabs. binding.homeAppbar.liftOnScrollTargetViewId = - when (tabMode) { - MusicMode.SONGS -> R.id.home_song_recycler - MusicMode.ALBUMS -> R.id.home_album_recycler - MusicMode.ARTISTS -> R.id.home_artist_recycler - MusicMode.GENRES -> R.id.home_genre_recycler - MusicMode.PLAYLISTS -> R.id.home_playlist_recycler + when (tabType) { + MusicType.SONGS -> R.id.home_song_recycler + MusicType.ALBUMS -> R.id.home_album_recycler + MusicType.ARTISTS -> R.id.home_artist_recycler + MusicType.GENRES -> R.id.home_genre_recycler + MusicType.PLAYLISTS -> R.id.home_playlist_recycler } - if (tabMode != MusicMode.PLAYLISTS) { + if (tabType != MusicType.PLAYLISTS) { logD("Flipping to shuffle button") binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) { playbackModel.shuffleAll() @@ -405,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() @@ -417,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) { @@ -432,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)) + } + } } } } @@ -460,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 -> { @@ -483,33 +416,27 @@ class HomeFragment : private fun handleDecision(decision: PlaylistDecision?) { if (decision == null) return - when (decision) { - is PlaylistDecision.New -> { - logD("Creating new playlist") - findNavController() - .navigateSafe( - HomeFragmentDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - } - is PlaylistDecision.Rename -> { - logD("Renaming ${decision.playlist}") - findNavController() - .navigateSafe(HomeFragmentDirections.renamePlaylist(decision.playlist.uid)) - } - is PlaylistDecision.Delete -> { - logD("Deleting ${decision.playlist}") - findNavController() - .navigateSafe(HomeFragmentDirections.deletePlaylist(decision.playlist.uid)) - } - is PlaylistDecision.Add -> { - logD("Adding ${decision.songs.size} to a playlist") - findNavController() - .navigateSafe( - HomeFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray())) + val directions = + when (decision) { + is PlaylistDecision.New -> { + logD("Creating new playlist") + HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + HomeFragmentDirections.renamePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + HomeFragmentDirections.deletePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} to a playlist") + HomeFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } } - } - musicModel.playlistDecision.consume() + findNavController().navigateSafe(directions) } private fun updateFab(songs: List, isFastScrolling: Boolean) { @@ -532,44 +459,40 @@ class HomeFragment : logD("Navigating to ${show.song}") findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid)) } - - // Songs should be scrolled to if the album matches, or a new detail - // fragment should be launched otherwise. is Show.SongAlbumDetails -> { logD("Navigating to the album of ${show.song}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid)) } - - // If the album matches, no need to do anything. Otherwise launch a new - // detail fragment. is Show.AlbumDetails -> { logD("Navigating to ${show.album}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid)) } - - // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") - setupAxisTransitions(MaterialSharedAxis.X) + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid)) } - is Show.SongArtistDetails -> { + is Show.SongArtistDecision -> { logD("Navigating to artist choices for ${show.song}") - findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.song.uid)) + findNavController() + .navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid)) } - is Show.AlbumArtistDetails -> { + is Show.AlbumArtistDecision -> { logD("Navigating to artist choices for ${show.album}") - findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.album.uid)) + findNavController() + .navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid)) } is Show.GenreDetails -> { logD("Navigating to ${show.genre}") + applyAxisTransition(MaterialSharedAxis.X) findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid)) } is Show.PlaylistDetails -> { logD("Navigating to ${show.playlist}") + applyAxisTransition(MaterialSharedAxis.X) findNavController() .navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid)) } @@ -577,6 +500,20 @@ class HomeFragment : } } + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + when (menu) { + is Menu.ForSong -> HomeFragmentDirections.openSongMenu(menu.parcel) + is Menu.ForAlbum -> HomeFragmentDirections.openAlbumMenu(menu.parcel) + 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) + } + private fun updateSelection(selected: List) { val binding = requireBinding() if (selected.isNotEmpty()) { @@ -591,7 +528,7 @@ class HomeFragment : } } - private fun setupAxisTransitions(axis: Int) { + private fun applyAxisTransition(axis: Int) { // Sanity check to avoid in-correct axis transitions check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) { "Not expecting Y axis transition" @@ -612,25 +549,25 @@ class HomeFragment : * [FragmentStateAdapter]. */ private class HomePagerAdapter( - private val tabs: List, + private val tabs: List, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner ) : FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle) { override fun getItemCount() = tabs.size + override fun createFragment(position: Int): Fragment = when (tabs[position]) { - MusicMode.SONGS -> SongListFragment() - MusicMode.ALBUMS -> AlbumListFragment() - MusicMode.ARTISTS -> ArtistListFragment() - MusicMode.GENRES -> GenreListFragment() - MusicMode.PLAYLISTS -> PlaylistListFragment() + MusicType.SONGS -> SongListFragment() + MusicType.ALBUMS -> AlbumListFragment() + MusicType.ARTISTS -> ArtistListFragment() + MusicType.GENRES -> GenreListFragment() + MusicType.PLAYLISTS -> PlaylistListFragment() } } private companion object { val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") - const val KEY_LAST_TRANSITION_AXIS = - BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" + const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 4e468ec95..5fc218cfe 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull @@ -75,9 +75,9 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) logD("Old tabs: $oldTabs") // The playlist tab is now parsed, but it needs to be made visible. - val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS } + val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS } check(playlistIndex > -1) // This should exist, otherwise we are in big trouble - oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS) + oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS) logD("New tabs: $oldTabs") sharedPreferences.edit { diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 4e471758a..bb9311c84 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -24,16 +24,17 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -49,73 +50,98 @@ class HomeViewModel @Inject constructor( private val homeSettings: HomeSettings, + private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings ) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { - private val _songsList = MutableStateFlow(listOf()) + private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ - val songsList: StateFlow> - get() = _songsList - private val _songsInstructions = MutableEvent() - /** Instructions for how to update [songsList] in the UI. */ - val songsInstructions: Event - get() = _songsInstructions - - private val _albumsLists = MutableStateFlow(listOf()) + val songList: StateFlow> + get() = _songList + + private val _songInstructions = MutableEvent() + /** Instructions for how to update [songList] in the UI. */ + val songInstructions: Event + get() = _songInstructions + + /** The current [Sort] used for [songList]. */ + val songSort: Sort + get() = listSettings.songSort + + /** The [PlaySong] instructions to use when playing a [Song]. */ + val playWith + get() = playbackSettings.playInListWith + + private val _albumList = MutableStateFlow(listOf()) /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */ - val albumsList: StateFlow> - get() = _albumsLists - private val _albumsInstructions = MutableEvent() - /** Instructions for how to update [albumsList] in the UI. */ - val albumsInstructions: Event - get() = _albumsInstructions - - private val _artistsList = MutableStateFlow(listOf()) + val albumList: StateFlow> + get() = _albumList + + private val _albumInstructions = MutableEvent() + /** Instructions for how to update [albumList] in the UI. */ + val albumInstructions: Event + get() = _albumInstructions + + /** The current [Sort] used for [albumList]. */ + val albumSort: Sort + get() = listSettings.albumSort + + private val _artistList = MutableStateFlow(listOf()) /** * A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that * if "Hide collaborators" is on, this list will not include collaborator [Artist]s. */ - val artistsList: MutableStateFlow> - get() = _artistsList - private val _artistsInstructions = MutableEvent() - /** Instructions for how to update [artistsList] in the UI. */ - val artistsInstructions: Event - get() = _artistsInstructions - - private val _genresList = MutableStateFlow(listOf()) + val artistList: MutableStateFlow> + get() = _artistList + + private val _artistInstructions = MutableEvent() + /** Instructions for how to update [artistList] in the UI. */ + val artistInstructions: Event + get() = _artistInstructions + + /** The current [Sort] used for [artistList]. */ + val artistSort: Sort + get() = listSettings.artistSort + + private val _genreList = MutableStateFlow(listOf()) /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */ - val genresList: StateFlow> - get() = _genresList - private val _genresInstructions = MutableEvent() - /** Instructions for how to update [genresList] in the UI. */ - val genresInstructions: Event - get() = _genresInstructions - - private val _playlistsList = MutableStateFlow(listOf()) + val genreList: StateFlow> + get() = _genreList + + private val _genreInstructions = MutableEvent() + /** Instructions for how to update [genreList] in the UI. */ + val genreInstructions: Event + get() = _genreInstructions + + /** The current [Sort] used for [genreList]. */ + val genreSort: Sort + get() = listSettings.genreSort + + private val _playlistList = MutableStateFlow(listOf()) /** A list of [Playlist]s, sorted by the preferred [Sort], to be shown in the home view. */ - val playlistsList: StateFlow> - get() = _playlistsList - private val _playlistsInstructions = MutableEvent() - /** Instructions for how to update [genresList] in the UI. */ - val playlistsInstructions: Event - get() = _playlistsInstructions + val playlistList: StateFlow> + get() = _playlistList - /** The [MusicMode] to use when playing a [Song] from the UI. */ - val playbackMode: MusicMode - get() = playbackSettings.inListPlaybackMode + private val _playlistInstructions = MutableEvent() + /** Instructions for how to update [genreList] in the UI. */ + val playlistInstructions: Event + get() = _playlistInstructions + + /** The current [Sort] used for [genreList]. */ + val playlistSort: Sort + get() = listSettings.playlistSort /** - * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible + * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabModes = makeTabModes() + var currentTabTypes = makeTabTypes() private set - private val _currentTabMode = MutableStateFlow(currentTabModes[0]) - /** The [MusicMode] of the currently shown [Tab]. */ - val currentTabMode: StateFlow = _currentTabMode + private val _currentTabType = MutableStateFlow(currentTabTypes[0]) + /** The [MusicType] of the currently shown [Tab]. */ + val currentTabType: StateFlow = _currentTabType private val _shouldRecreate = MutableEvent() /** @@ -130,6 +156,10 @@ constructor( /** A marker for whether the user is fast-scrolling in the home view or not. */ val isFastScrolling: StateFlow = _isFastScrolling + private val _showOuter = MutableEvent() + val showOuter: Event + get() = _showOuter + init { musicRepository.addUpdateListener(this) homeSettings.registerListener(this) @@ -147,13 +177,13 @@ constructor( logD("Refreshing library") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. - _songsInstructions.put(UpdateInstructions.Diff) - _songsList.value = musicSettings.songSort.songs(deviceLibrary.songs) - _albumsInstructions.put(UpdateInstructions.Diff) - _albumsLists.value = musicSettings.albumSort.albums(deviceLibrary.albums) - _artistsInstructions.put(UpdateInstructions.Diff) - _artistsList.value = - musicSettings.artistSort.artists( + _songInstructions.put(UpdateInstructions.Diff) + _songList.value = listSettings.songSort.songs(deviceLibrary.songs) + _albumInstructions.put(UpdateInstructions.Diff) + _albumList.value = listSettings.albumSort.albums(deviceLibrary.albums) + _artistInstructions.put(UpdateInstructions.Diff) + _artistList.value = + listSettings.artistSort.artists( if (homeSettings.shouldHideCollaborators) { logD("Filtering collaborator artists") // Hide Collaborators is enabled, filter out collaborators. @@ -162,22 +192,22 @@ constructor( logD("Using all artists") deviceLibrary.artists }) - _genresInstructions.put(UpdateInstructions.Diff) - _genresList.value = musicSettings.genreSort.genres(deviceLibrary.genres) + _genreInstructions.put(UpdateInstructions.Diff) + _genreList.value = listSettings.genreSort.genres(deviceLibrary.genres) } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { logD("Refreshing playlists") - _playlistsInstructions.put(UpdateInstructions.Diff) - _playlistsList.value = musicSettings.playlistSort.playlists(userLibrary.playlists) + _playlistInstructions.put(UpdateInstructions.Diff) + _playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists) } } override fun onTabsChanged() { // Tabs changed, update the current tabs and set up a re-create event. - currentTabModes = makeTabModes() - logD("Updating tabs: ${currentTabMode.value}") + currentTabTypes = makeTabTypes() + logD("Updating tabs: ${currentTabType.value}") _shouldRecreate.put(Unit) } @@ -189,69 +219,68 @@ constructor( } /** - * Get the preferred [Sort] for a given [Tab]. + * Apply a new [Sort] to [songList]. * - * @param tabMode The [MusicMode] of the [Tab] desired. - * @return The [Sort] preferred for that [Tab] + * @param sort The [Sort] to apply. */ - fun getSortForTab(tabMode: MusicMode) = - when (tabMode) { - MusicMode.SONGS -> musicSettings.songSort - MusicMode.ALBUMS -> musicSettings.albumSort - MusicMode.ARTISTS -> musicSettings.artistSort - MusicMode.GENRES -> musicSettings.genreSort - MusicMode.PLAYLISTS -> musicSettings.playlistSort - } + fun applySongSort(sort: Sort) { + listSettings.songSort = sort + _songInstructions.put(UpdateInstructions.Replace(0)) + _songList.value = listSettings.songSort.songs(_songList.value) + } /** - * Update the preferred [Sort] for the current [Tab]. Will update corresponding list. + * Apply a new [Sort] to [albumList]. * - * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. + * @param sort The [Sort] to apply. */ - fun setSortForCurrentTab(sort: Sort) { - // Can simply re-sort the current list of items without having to access the library. - when (val mode = _currentTabMode.value) { - MusicMode.SONGS -> { - logD("Updating song [$mode] sort mode to $sort") - musicSettings.songSort = sort - _songsInstructions.put(UpdateInstructions.Replace(0)) - _songsList.value = sort.songs(_songsList.value) - } - MusicMode.ALBUMS -> { - logD("Updating album [$mode] sort mode to $sort") - musicSettings.albumSort = sort - _albumsInstructions.put(UpdateInstructions.Replace(0)) - _albumsLists.value = sort.albums(_albumsLists.value) - } - MusicMode.ARTISTS -> { - logD("Updating artist [$mode] sort mode to $sort") - musicSettings.artistSort = sort - _artistsInstructions.put(UpdateInstructions.Replace(0)) - _artistsList.value = sort.artists(_artistsList.value) - } - MusicMode.GENRES -> { - logD("Updating genre [$mode] sort mode to $sort") - musicSettings.genreSort = sort - _genresInstructions.put(UpdateInstructions.Replace(0)) - _genresList.value = sort.genres(_genresList.value) - } - MusicMode.PLAYLISTS -> { - logD("Updating playlist [$mode] sort mode to $sort") - musicSettings.playlistSort = sort - _playlistsInstructions.put(UpdateInstructions.Replace(0)) - _playlistsList.value = sort.playlists(_playlistsList.value) - } - } + fun applyAlbumSort(sort: Sort) { + listSettings.albumSort = sort + _albumInstructions.put(UpdateInstructions.Replace(0)) + _albumList.value = listSettings.albumSort.albums(_albumList.value) + } + + /** + * Apply a new [Sort] to [artistList]. + * + * @param sort The [Sort] to apply. + */ + fun applyArtistSort(sort: Sort) { + listSettings.artistSort = sort + _artistInstructions.put(UpdateInstructions.Replace(0)) + _artistList.value = listSettings.artistSort.artists(_artistList.value) + } + + /** + * Apply a new [Sort] to [genreList]. + * + * @param sort The [Sort] to apply. + */ + fun applyGenreSort(sort: Sort) { + listSettings.genreSort = sort + _genreInstructions.put(UpdateInstructions.Replace(0)) + _genreList.value = listSettings.genreSort.genres(_genreList.value) + } + + /** + * Apply a new [Sort] to [playlistList]. + * + * @param sort The [Sort] to apply. + */ + fun applyPlaylistSort(sort: Sort) { + listSettings.playlistSort = sort + _playlistInstructions.put(UpdateInstructions.Replace(0)) + _playlistList.value = listSettings.playlistSort.playlists(_playlistList.value) } /** - * Update [currentTabMode] to reflect a new ViewPager2 position + * Update [currentTabType] to reflect a new ViewPager2 position * * @param pagerPos The new position of the ViewPager2 instance. */ fun synchronizeTabPosition(pagerPos: Int) { - logD("Updating current tab to ${currentTabModes[pagerPos]}") - _currentTabMode.value = currentTabModes[pagerPos] + logD("Updating current tab to ${currentTabTypes[pagerPos]}") + _currentTabType.value = currentTabTypes[pagerPos] } /** @@ -264,12 +293,26 @@ constructor( _isFastScrolling.value = isFastScrolling } + fun showSettings() { + _showOuter.put(Outer.Settings) + } + + fun showAbout() { + _showOuter.put(Outer.About) + } + /** - * Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration. + * Create a list of [MusicType]s representing a simpler version of the [Tab] configuration. * - * @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in + * @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in * the same way as the configuration. */ - private fun makeTabModes() = - homeSettings.homeTabs.filterIsInstance().map { it.mode } + private fun makeTabTypes() = + homeSettings.homeTabs.filterIsInstance().map { it.type } +} + +sealed interface Outer { + data object Settings : Outer + + data object About : Outer } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt index 620ac018f..ae546d137 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt @@ -123,8 +123,11 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) } override fun isAutoMirrored(): Boolean = true + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(colorFilter: ColorFilter?) {} + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT private fun updatePath() { 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 b4aac9121..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 @@ -21,7 +21,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle import android.text.format.DateUtils import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint @@ -32,14 +31,13 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.AlbumViewHolder -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song @@ -59,10 +57,10 @@ class AlbumListFragment : FastScrollRecyclerView.Listener, FastScrollRecyclerView.PopupProvider { private val homeModel: HomeViewModel by activityViewModels() - override val detailModel: DetailViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val albumAdapter = AlbumAdapter(this) // Save memory by re-using the same formatter and string builder when creating popup text private val formatterSb = StringBuilder(64) @@ -81,8 +79,8 @@ class AlbumListFragment : listener = this@AlbumListFragment } - collectImmediately(homeModel.albumsList, ::updateAlbums) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.albumList, ::updateAlbums) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -97,9 +95,9 @@ class AlbumListFragment : } override fun getPopup(pos: Int): String? { - val album = homeModel.albumsList.value[pos] + val album = homeModel.albumList.value[pos] // Change how we display the popup depending on the current sort mode. - return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) { + return when (homeModel.albumSort.mode) { // By Name -> Use Name is Sort.Mode.ByName -> album.name.thumb @@ -141,12 +139,12 @@ class AlbumListFragment : detailModel.showAlbum(item) } - override fun onOpenMenu(item: Album, anchor: View) { - openMusicMenu(anchor, R.menu.menu_album_actions, item) + override fun onOpenMenu(item: Album) { + listModel.openMenu(R.menu.album, item) } private fun updateAlbums(albums: List) { - albumAdapter.update(albums, homeModel.albumsInstructions.consume()) + albumAdapter.update(albums, homeModel.albumInstructions.consume()) } private fun updateSelection(selection: 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 b66c6e965..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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint @@ -30,21 +29,20 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.ArtistViewHolder -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.nonZeroOrNull +import org.oxycblt.auxio.util.positiveOrNull /** * A [ListFragment] that shows a list of [Artist]s. @@ -57,10 +55,10 @@ class ArtistListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val detailModel: DetailViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val artistAdapter = ArtistAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = @@ -76,8 +74,8 @@ class ArtistListFragment : listener = this@ArtistListFragment } - collectImmediately(homeModel.artistsList, ::updateArtists) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.artistList, ::updateArtists) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -92,9 +90,9 @@ class ArtistListFragment : } override fun getPopup(pos: Int): String? { - val artist = homeModel.artistsList.value[pos] + val artist = homeModel.artistList.value[pos] // Change how we display the popup depending on the current sort mode. - return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) { + return when (homeModel.artistSort.mode) { // By Name -> Use Name is Sort.Mode.ByName -> artist.name.thumb @@ -102,7 +100,7 @@ class ArtistListFragment : is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) // Count -> Use song count - is Sort.Mode.ByCount -> artist.songs.size.nonZeroOrNull()?.toString() + is Sort.Mode.ByCount -> artist.songs.size.positiveOrNull()?.toString() // Unsupported sort, error gracefully else -> null @@ -117,12 +115,12 @@ class ArtistListFragment : detailModel.showArtist(item) } - override fun onOpenMenu(item: Artist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + override fun onOpenMenu(item: Artist) { + listModel.openMenu(R.menu.parent, item) } private fun updateArtists(artists: List) { - artistAdapter.update(artists, homeModel.artistsInstructions.consume()) + artistAdapter.update(artists, homeModel.artistInstructions.consume()) } private fun updateSelection(selection: 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 d751e3699..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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint @@ -30,14 +29,13 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.GenreViewHolder -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song @@ -56,10 +54,10 @@ class GenreListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val detailModel: DetailViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val genreAdapter = GenreAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = @@ -75,8 +73,8 @@ class GenreListFragment : listener = this@GenreListFragment } - collectImmediately(homeModel.genresList, ::updateGenres) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.genreList, ::updateGenres) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -91,9 +89,9 @@ class GenreListFragment : } override fun getPopup(pos: Int): String? { - val genre = homeModel.genresList.value[pos] + val genre = homeModel.genreList.value[pos] // Change how we display the popup depending on the current sort mode. - return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { + return when (homeModel.genreSort.mode) { // By Name -> Use Name is Sort.Mode.ByName -> genre.name.thumb @@ -116,12 +114,12 @@ class GenreListFragment : detailModel.showGenre(item) } - override fun onOpenMenu(item: Genre, anchor: View) { - openMusicMenu(anchor, R.menu.menu_parent_actions, item) + override fun onOpenMenu(item: Genre) { + listModel.openMenu(R.menu.parent, item) } private fun updateGenres(genres: List) { - genreAdapter.update(genres, homeModel.genresInstructions.consume()) + genreAdapter.update(genres, homeModel.genreInstructions.consume()) } private fun updateSelection(selection: 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 405dbe312..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 @@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list import android.os.Bundle import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import org.oxycblt.auxio.R @@ -29,13 +28,12 @@ import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.PlaylistViewHolder -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist @@ -54,10 +52,10 @@ class PlaylistListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val detailModel: DetailViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val playlistAdapter = PlaylistAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = @@ -73,8 +71,8 @@ class PlaylistListFragment : listener = this@PlaylistListFragment } - collectImmediately(homeModel.playlistsList, ::updatePlaylists) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.playlistList, ::updatePlaylists) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -89,9 +87,9 @@ class PlaylistListFragment : } override fun getPopup(pos: Int): String? { - val playlist = homeModel.playlistsList.value[pos] + val playlist = homeModel.playlistList.value[pos] // Change how we display the popup depending on the current sort mode. - return when (homeModel.getSortForTab(MusicMode.GENRES).mode) { + return when (homeModel.playlistSort.mode) { // By Name -> Use Name is Sort.Mode.ByName -> playlist.name.thumb @@ -114,12 +112,12 @@ class PlaylistListFragment : detailModel.showPlaylist(item) } - override fun onOpenMenu(item: Playlist, anchor: View) { - openMusicMenu(anchor, R.menu.menu_playlist_actions, item) + override fun onOpenMenu(item: Playlist) { + listModel.openMenu(R.menu.playlist, item) } private fun updatePlaylists(playlists: List) { - playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume()) + playlistAdapter.update(playlists, homeModel.playlistInstructions.consume()) } private fun updateSelection(selection: 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 d827adbf7..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 @@ -21,24 +21,21 @@ package org.oxycblt.auxio.home.list import android.os.Bundle import android.text.format.DateUtils import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import dagger.hilt.android.AndroidEntryPoint import java.util.Formatter import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentHomeListBinding -import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song @@ -58,10 +55,9 @@ class SongListFragment : FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - override val detailModel: DetailViewModel by activityViewModels() - override val playbackModel: PlaybackViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() private val songAdapter = SongAdapter(this) // Save memory by re-using the same formatter and string builder when creating popup text private val formatterSb = StringBuilder(64) @@ -80,8 +76,8 @@ class SongListFragment : listener = this@SongListFragment } - collectImmediately(homeModel.songsList, ::updateSongs) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(homeModel.songList, ::updateSongs) + collectImmediately(listModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -96,11 +92,11 @@ class SongListFragment : } override fun getPopup(pos: Int): String? { - val song = homeModel.songsList.value[pos] + val song = homeModel.songList.value[pos] // Change how we display the popup depending on the current sort mode. // Note: We don't use the more correct individual artist name here, as sorts are largely // based off the names of the parent objects and not the child objects. - return when (homeModel.getSortForTab(MusicMode.SONGS).mode) { + return when (homeModel.songSort.mode) { // Name -> Use name is Sort.Mode.ByName -> song.name.thumb @@ -139,15 +135,15 @@ class SongListFragment : } override fun onRealClick(item: Song) { - playbackModel.playFrom(item, homeModel.playbackMode) + playbackModel.play(item, homeModel.playWith) } - override fun onOpenMenu(item: Song, anchor: View) { - openMusicMenu(anchor, R.menu.menu_song_actions, item) + override fun onOpenMenu(item: Song) { + listModel.openMenu(R.menu.song, item, homeModel.playWith) } private fun updateSongs(songs: List) { - songAdapter.update(songs, homeModel.songsInstructions.consume()) + songAdapter.update(songs, homeModel.songInstructions.consume()) } private fun updateSelection(selection: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/sort/AlbumSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/sort/AlbumSortDialog.kt new file mode 100644 index 000000000..39efb1ca1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/sort/AlbumSortDialog.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Auxio Project + * AlbumSortDialog.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.sort + +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog + +/** + * A [SortDialog] that controls the [Sort] of [HomeViewModel.albumList]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class AlbumSortDialog : SortDialog() { + private val homeModel: HomeViewModel by activityViewModels() + + override fun getInitialSort() = homeModel.albumSort + + override fun applyChosenSort(sort: Sort) { + homeModel.applyAlbumSort(sort) + } + + override fun getModeChoices() = + listOf( + Sort.Mode.ByName, + Sort.Mode.ByArtist, + Sort.Mode.ByDate, + Sort.Mode.ByDuration, + Sort.Mode.ByCount, + Sort.Mode.ByDateAdded) +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/sort/ArtistSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/sort/ArtistSortDialog.kt new file mode 100644 index 000000000..f3aeacd17 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/sort/ArtistSortDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Auxio Project + * ArtistSortDialog.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.sort + +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog + +/** + * A [SortDialog] that controls the [Sort] of [HomeViewModel.artistList]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class ArtistSortDialog : SortDialog() { + private val homeModel: HomeViewModel by activityViewModels() + + override fun getInitialSort() = homeModel.artistSort + + override fun applyChosenSort(sort: Sort) { + homeModel.applyArtistSort(sort) + } + + override fun getModeChoices() = + listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount) +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/sort/GenreSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/sort/GenreSortDialog.kt new file mode 100644 index 000000000..e62ac8272 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/sort/GenreSortDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Auxio Project + * GenreSortDialog.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.sort + +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog + +/** + * A [SortDialog] that controls the [Sort] of [HomeViewModel.genreList]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class GenreSortDialog : SortDialog() { + private val homeModel: HomeViewModel by activityViewModels() + + override fun getInitialSort() = homeModel.genreSort + + override fun applyChosenSort(sort: Sort) { + homeModel.applyGenreSort(sort) + } + + override fun getModeChoices() = + listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount) +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/sort/PlaylistSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/sort/PlaylistSortDialog.kt new file mode 100644 index 000000000..d87f126e9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/sort/PlaylistSortDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaylistSortDialog.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.sort + +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog + +/** + * A [SortDialog] that controls the [Sort] of [HomeViewModel.playlistList]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistSortDialog : SortDialog() { + private val homeModel: HomeViewModel by activityViewModels() + + override fun getInitialSort() = homeModel.playlistSort + + override fun applyChosenSort(sort: Sort) { + homeModel.applyPlaylistSort(sort) + } + + override fun getModeChoices() = + listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount) +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/sort/SongSortDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/sort/SongSortDialog.kt new file mode 100644 index 000000000..a961a39c4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/sort/SongSortDialog.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Auxio Project + * SongSortDialog.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.sort + +import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.home.HomeViewModel +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.list.sort.SortDialog + +/** + * A [SortDialog] that controls the [Sort] of [HomeViewModel.songList]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class SongSortDialog : SortDialog() { + private val homeModel: HomeViewModel by activityViewModels() + + override fun getInitialSort() = homeModel.songSort + + override fun applyChosenSort(sort: Sort) { + homeModel.applySongSort(sort) + } + + override fun getModeChoices() = + listOf( + Sort.Mode.ByName, + Sort.Mode.ByArtist, + Sort.Mode.ByAlbum, + Sort.Mode.ByDate, + Sort.Mode.ByDuration, + Sort.Mode.ByDateAdded) +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 36aed93bf..73170ef4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -22,7 +22,7 @@ import android.content.Context import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType /** * A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.MusicMode * @param tabs Current tab configuration from settings * @author Alexander Capehart (OxygenCobalt) */ -class AdaptiveTabStrategy(context: Context, private val tabs: List) : +class AdaptiveTabStrategy(context: Context, private val tabs: List) : TabLayoutMediator.TabConfigurationStrategy { private val width = context.resources.configuration.smallestScreenWidthDp @@ -41,23 +41,23 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : val string: Int when (tabs[position]) { - MusicMode.SONGS -> { + MusicType.SONGS -> { icon = R.drawable.ic_song_24 string = R.string.lbl_songs } - MusicMode.ALBUMS -> { + MusicType.ALBUMS -> { icon = R.drawable.ic_album_24 string = R.string.lbl_albums } - MusicMode.ARTISTS -> { + MusicType.ARTISTS -> { icon = R.drawable.ic_artist_24 string = R.string.lbl_artists } - MusicMode.GENRES -> { + MusicType.GENRES -> { icon = R.drawable.ic_genre_24 string = R.string.lbl_genres } - MusicMode.PLAYLISTS -> { + MusicType.PLAYLISTS -> { icon = R.drawable.ic_playlist_24 string = R.string.lbl_playlists } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 5cacd084b..aee964e45 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -18,30 +18,30 @@ package org.oxycblt.auxio.home.tabs -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW /** * A representation of a library tab suitable for configuration. * - * @param mode The type of list in the home view this instance corresponds to. + * @param type The type of list in the home view this instance corresponds to. * @author Alexander Capehart (OxygenCobalt) */ -sealed class Tab(open val mode: MusicMode) { +sealed class Tab(open val type: MusicType) { /** * A visible tab. This will be visible in the home and tab configuration views. * - * @param mode The type of list in the home view this instance corresponds to. + * @param type The type of list in the home view this instance corresponds to. */ - data class Visible(override val mode: MusicMode) : Tab(mode) + data class Visible(override val type: MusicType) : Tab(type) /** * A visible tab. This will be visible in the tab configuration view, but not in the home view. * - * @param mode The type of list in the home view this instance corresponds to. + * @param type The type of list in the home view this instance corresponds to. */ - data class Invisible(override val mode: MusicMode) : Tab(mode) + data class Invisible(override val type: MusicType) : Tab(type) companion object { // Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs @@ -67,14 +67,14 @@ sealed class Tab(open val mode: MusicMode) { */ const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_1100 - /** Maps between the integer code in the tab sequence and it's [MusicMode]. */ + /** Maps between the integer code in the tab sequence and it's [MusicType]. */ private val MODE_TABLE = arrayOf( - MusicMode.SONGS, - MusicMode.ALBUMS, - MusicMode.ARTISTS, - MusicMode.GENRES, - MusicMode.PLAYLISTS) + MusicType.SONGS, + MusicType.ALBUMS, + MusicType.ARTISTS, + MusicType.GENRES, + MusicType.PLAYLISTS) /** * Convert an array of [Tab]s into it's integer representation. @@ -84,7 +84,7 @@ sealed class Tab(open val mode: MusicMode) { */ fun toIntCode(tabs: Array): Int { // Like when deserializing, make sure there are no duplicate tabs for whatever reason. - val distinct = tabs.distinctBy { it.mode } + val distinct = tabs.distinctBy { it.type } if (tabs.size != distinct.size) { logW( "Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") @@ -95,8 +95,8 @@ sealed class Tab(open val mode: MusicMode) { for (tab in distinct) { val bin = when (tab) { - is Visible -> 1.shl(3) or MODE_TABLE.indexOf(tab.mode) - is Invisible -> MODE_TABLE.indexOf(tab.mode) + is Visible -> 1.shl(3) or MODE_TABLE.indexOf(tab.type) + is Invisible -> MODE_TABLE.indexOf(tab.type) } sequence = sequence or bin.shl(shift) @@ -131,7 +131,7 @@ sealed class Tab(open val mode: MusicMode) { } // Make sure there are no duplicate tabs - val distinct = tabs.distinctBy { it.mode } + val distinct = tabs.distinctBy { it.type } if (tabs.size != distinct.size) { logW( "Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index 277c0c39b..736b5ba30 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemTabBinding import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.list.recycler.DialogRecyclerView -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.logD @@ -42,7 +42,9 @@ class TabAdapter(private val listener: EditClickListListener) : private set override fun getItemCount() = tabs.size + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.from(parent) + override fun onBindViewHolder(holder: TabViewHolder, position: Int) { holder.bind(tabs[position], listener) } @@ -107,14 +109,14 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) : fun bind(tab: Tab, listener: EditClickListListener) { listener.bind(tab, this, dragHandle = binding.tabDragHandle) binding.tabCheckBox.apply { - // Update the CheckBox name to align with the mode + // Update the CheckBox name to align with the type setText( - when (tab.mode) { - MusicMode.SONGS -> R.string.lbl_songs - MusicMode.ALBUMS -> R.string.lbl_albums - MusicMode.ARTISTS -> R.string.lbl_artists - MusicMode.GENRES -> R.string.lbl_genres - MusicMode.PLAYLISTS -> R.string.lbl_playlists + when (tab.type) { + MusicType.SONGS -> R.string.lbl_songs + MusicType.ALBUMS -> R.string.lbl_albums + MusicType.ARTISTS -> R.string.lbl_artists + MusicType.GENRES -> R.string.lbl_genres + MusicType.PLAYLISTS -> R.string.lbl_playlists }) // Unlike in other adapters, we update the checked state alongside diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index c7dadd8d2..57bc73c15 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -30,17 +30,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.list.EditClickListListener -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. + * A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab] + * configuration. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class TabCustomizeDialog : - ViewBindingDialogFragment(), EditClickListListener { + ViewBindingMaterialDialogFragment(), EditClickListListener { private val tabAdapter = TabAdapter(this) private var touchHelper: ItemTouchHelper? = null @Inject lateinit var homeSettings: HomeSettings @@ -90,13 +91,13 @@ class TabCustomizeDialog : override fun onClick(item: Tab, viewHolder: RecyclerView.ViewHolder) { // We will need the exact index of the tab to update on in order to // notify the adapter of the change. - val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode } + val index = tabAdapter.tabs.indexOfFirst { it.type == item.type } val old = tabAdapter.tabs[index] val new = when (old) { // Invert the visibility of the tab - is Tab.Visible -> Tab.Invisible(old.mode) - is Tab.Invisible -> Tab.Visible(old.mode) + is Tab.Visible -> Tab.Invisible(old.type) + is Tab.Invisible -> Tab.Visible(old.type) } logD("Flipping tab visibility [from: $old to: $new]") tabAdapter.setTab(index, new) diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 9180c0a35..792755dc7 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -83,35 +83,40 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val image: ImageView - data class PlaybackIndicator( + private data class PlaybackIndicator( val view: ImageView, val playingDrawable: AnimationDrawable, val pausedDrawable: Drawable ) + private val playbackIndicator: PlaybackIndicator? private val selectionBadge: ImageView? + private val sizing: Int @DimenRes private val iconSizeRes: Int? - @DimenRes private val cornerRadiusRes: Int? + @DimenRes private var cornerRadiusRes: Int? private var fadeAnimator: ValueAnimator? = null private val indicatorMatrix = Matrix() private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() + private data class Cover( + val songs: Collection, + val desc: String, + @DrawableRes val errorRes: Int + ) + + private var currentCover: Cover? = null + init { // Obtain some StyledImageView attributes to use later when theming the custom view. @SuppressLint("CustomViewStyleable") val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView) - val sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing) + sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing) iconSizeRes = SIZING_ICON_SIZE[sizing] - cornerRadiusRes = - if (uiSettings.roundMode) { - SIZING_CORNER_RADII[sizing] - } else { - null - } + cornerRadiusRes = getCornerRadiusRes() val playbackIndicatorEnabled = styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true) @@ -161,19 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr playbackIndicator?.run { addView(view) } - // Add backgrounds to each child for visual consistency - for (child in children) { - child.apply { - // If there are rounded corners, we want to make sure view content will be cropped - // with it. - clipToOutline = this != image - background = - MaterialShapeDrawable().apply { - fillColor = context.getColorCompat(R.color.sel_cover_bg) - setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f) - } - } - } + applyBackgroundsToChildren() // The selection badge has it's own background we don't want overridden, add it after // all other elements. @@ -261,6 +254,29 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } } + private fun getCornerRadiusRes() = + if (!isInEditMode && uiSettings.roundMode) { + SIZING_CORNER_RADII[sizing] + } else { + null + } + + private fun applyBackgroundsToChildren() { + // Add backgrounds to each child for visual consistency + for (child in children) { + child.apply { + // If there are rounded corners, we want to make sure view content will be cropped + // with it. + clipToOutline = this != image + background = + MaterialShapeDrawable().apply { + fillColor = context.getColorCompat(R.color.sel_cover_bg) + setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f) + } + } + } + } + private fun invalidateRootAlpha() { alpha = if (isEnabled || isSelected) 1f else 0.5f } @@ -401,6 +417,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr CoilUtils.dispose(image) imageLoader.enqueue(request.build()) contentDescription = desc + currentCover = Cover(songs, desc, errorRes) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt index 1a9a01b24..232d903a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -39,7 +39,7 @@ interface ImageSettings : Settings { interface Listener { /** Called when [coverMode] changes. */ - fun onCoverModeChanged() {} + fun onImageSettingsChanged() {} } } @@ -77,9 +77,10 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context } override fun onSettingChanged(key: String, listener: ImageSettings.Listener) { - if (key == getString(R.string.set_key_cover_mode)) { - logD("Dispatching cover mode setting change") - listener.onCoverModeChanged() + if (key == getString(R.string.set_key_cover_mode) || + key == getString(R.string.set_key_square_covers)) { + logD("Dispatching image setting change") + listener.onImageSettingsChanged() } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 537d8e874..899867eb0 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -50,7 +50,7 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logE diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt index 7931f63e6..546b03a49 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -18,25 +18,9 @@ package org.oxycblt.auxio.list -import android.view.View -import androidx.annotation.MenuRes -import androidx.appcompat.widget.PopupMenu -import androidx.core.view.MenuCompat import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding -import org.oxycblt.auxio.R -import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.list.selection.SelectionFragment -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.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW -import org.oxycblt.auxio.util.share -import org.oxycblt.auxio.util.showToast /** * A Fragment containing a selectable list. @@ -45,15 +29,6 @@ import org.oxycblt.auxio.util.showToast */ abstract class ListFragment : SelectionFragment(), SelectableListListener { - protected abstract val detailModel: DetailViewModel - private var currentMenu: PopupMenu? = null - - override fun onDestroyBinding(binding: VB) { - super.onDestroyBinding(binding) - currentMenu?.dismiss() - currentMenu = null - } - /** * Called when [onClick] is called, but does not result in the item being selected. This more or * less corresponds to an [onClick] implementation in a non-[ListFragment]. @@ -63,9 +38,9 @@ abstract class ListFragment : abstract fun onRealClick(item: T) final override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) { - if (selectionModel.selected.value.isNotEmpty()) { + if (listModel.selected.value.isNotEmpty()) { // Map clicking an item to selecting an item when items are already selected. - selectionModel.select(item) + listModel.select(item) } else { // Delegate to the concrete implementation when we don't select the item. onRealClick(item) @@ -73,307 +48,6 @@ abstract class ListFragment : } final override fun onSelect(item: T) { - selectionModel.select(item) - } - - /** - * Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and closed - * when the view is destroyed. If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param song The [Song] to create the menu for. - */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) { - logD("Launching new song menu: ${song.name}") - - openMenu(anchor, menuRes) { - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play_next -> { - playbackModel.playNext(song) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(song) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_go_artist -> { - detailModel.showArtist(song) - true - } - R.id.action_go_album -> { - detailModel.showAlbum(song.album) - true - } - R.id.action_share -> { - requireContext().share(song) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(song) - true - } - R.id.action_song_detail -> { - detailModel.showSong(song) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } - } - } - } - - /** - * Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and - * closed when the view is destroyed. If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param album The [Album] to create the menu for. - */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) { - logD("Launching new album menu: ${album.name}") - - openMenu(anchor, menuRes) { - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(album) - true - } - R.id.action_shuffle -> { - playbackModel.shuffle(album) - true - } - R.id.action_play_next -> { - playbackModel.playNext(album) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(album) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_go_artist -> { - detailModel.showArtist(album) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(album) - true - } - R.id.action_share -> { - requireContext().share(album) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } - } - } - } - - /** - * Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and - * closed when the view is destroyed. If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param artist The [Artist] to create the menu for. - */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) { - logD("Launching new artist menu: ${artist.name}") - - openMenu(anchor, menuRes) { - val playable = artist.songs.isNotEmpty() - if (!playable) { - logD("Artist is empty, disabling playback/playlist/share options") - } - menu.findItem(R.id.action_play).isEnabled = playable - menu.findItem(R.id.action_shuffle).isEnabled = playable - menu.findItem(R.id.action_play_next).isEnabled = playable - menu.findItem(R.id.action_queue_add).isEnabled = playable - menu.findItem(R.id.action_playlist_add).isEnabled = playable - menu.findItem(R.id.action_share).isEnabled = playable - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(artist) - true - } - R.id.action_shuffle -> { - playbackModel.shuffle(artist) - true - } - R.id.action_play_next -> { - playbackModel.playNext(artist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(artist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(artist) - true - } - R.id.action_share -> { - requireContext().share(artist) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } - } - } - } - - /** - * Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and - * closed when the view is destroyed. If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param genre The [Genre] to create the menu for. - */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) { - logD("Launching new genre menu: ${genre.name}") - - openMenu(anchor, menuRes) { - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(genre) - true - } - R.id.action_shuffle -> { - playbackModel.shuffle(genre) - true - } - R.id.action_play_next -> { - playbackModel.playNext(genre) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(genre) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_playlist_add -> { - musicModel.addToPlaylist(genre) - true - } - R.id.action_share -> { - requireContext().share(genre) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } - } - } - } - - /** - * Opens a menu in the context of a [Playlist]. This menu will be managed by the Fragment and - * closed when the view is destroyed. If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param playlist The [Playlist] to create the menu for. - */ - protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) { - logD("Launching new playlist menu: ${playlist.name}") - - openMenu(anchor, menuRes) { - val playable = playlist.songs.isNotEmpty() - menu.findItem(R.id.action_play).isEnabled = playable - menu.findItem(R.id.action_shuffle).isEnabled = playable - menu.findItem(R.id.action_play_next).isEnabled = playable - menu.findItem(R.id.action_queue_add).isEnabled = playable - menu.findItem(R.id.action_share).isEnabled = playable - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_play -> { - playbackModel.play(playlist) - true - } - R.id.action_shuffle -> { - playbackModel.shuffle(playlist) - true - } - R.id.action_play_next -> { - playbackModel.playNext(playlist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_queue_add -> { - playbackModel.addToQueue(playlist) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_rename -> { - musicModel.renamePlaylist(playlist) - true - } - R.id.action_delete -> { - musicModel.deletePlaylist(playlist) - true - } - R.id.action_share -> { - requireContext().share(playlist) - true - } - else -> { - logW("Unexpected menu item selected") - false - } - } - } - } - } - - /** - * Open a menu. This menu will be managed by the Fragment and closed when the view is destroyed. - * If a menu is already opened, this call is ignored. - * - * @param anchor The [View] to anchor the menu to. - * @param menuRes The resource of the menu to load. - * @param block A block that is ran within [PopupMenu] that allows further configuration. - */ - protected fun openMenu(anchor: View, @MenuRes menuRes: Int, block: PopupMenu.() -> Unit) { - if (currentMenu != null) { - logD("Menu already present, not launching") - return - } - - logD("Opening popup menu menu") - - currentMenu = - PopupMenu(requireContext(), anchor).apply { - inflate(menuRes) - MenuCompat.setGroupDividerEnabled(menu, true) - block() - setOnDismissListener { currentMenu = null } - show() - } + listModel.select(item) } } diff --git a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt b/app/src/main/java/org/oxycblt/auxio/list/ListModule.kt similarity index 68% rename from app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt rename to app/src/main/java/org/oxycblt/auxio/list/ListModule.kt index 5da90ab54..521bc4283 100644 --- a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2023 Auxio Project - * TestingUtil.kt is part of Auxio. + * ListModule.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,13 +16,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.util +package org.oxycblt.auxio.list -import androidx.lifecycle.ViewModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent -private val VM_CLEAR_METHOD = - ViewModel::class.java.getDeclaredMethod("clear").apply { isAccessible = true } - -fun ViewModel.forceClear() { - VM_CLEAR_METHOD.invoke(this) +@Module +@InstallIn(SingletonComponent::class) +interface ListModule { + @Binds fun settings(settings: ListSettingsImpl): ListSettings } diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt new file mode 100644 index 000000000..3f3388b73 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2023 Auxio Project + * ListSettings.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.list + +import android.content.Context +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.settings.Settings + +interface ListSettings : Settings { + /** The [Sort] mode used in Song lists. */ + var songSort: Sort + /** The [Sort] mode used in Album lists. */ + var albumSort: Sort + /** The [Sort] mode used in Artist lists. */ + var artistSort: Sort + /** The [Sort] mode used in Genre lists. */ + var genreSort: Sort + /** The [Sort] mode used in Playlist lists. */ + var playlistSort: Sort + /** The [Sort] mode used in an Album's Song list. */ + var albumSongSort: Sort + /** The [Sort] mode used in an Artist's Song list. */ + var artistSongSort: Sort + /** The [Sort] mode used in a Genre's Song list. */ + var genreSongSort: Sort +} + +class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) : + Settings.Impl(context), ListSettings { + override var songSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_songs_sort), value.intCode) + apply() + } + } + + override var albumSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_albums_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_albums_sort), value.intCode) + apply() + } + } + + override var artistSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_artists_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_artists_sort), value.intCode) + apply() + } + } + + override var genreSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_genres_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_genres_sort), value.intCode) + apply() + } + } + + override var playlistSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_playlists_sort), value.intCode) + apply() + } + } + + override var albumSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_album_songs_sort), value.intCode) + apply() + } + } + + override var artistSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_artist_songs_sort), value.intCode) + apply() + } + } + + override var genreSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_genre_songs_sort), value.intCode) + apply() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt new file mode 100644 index 000000000..e223f439b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2023 Auxio Project + * ListViewModel.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.list + +import androidx.annotation.MenuRes +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.list.menu.Menu +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.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaySong +import org.oxycblt.auxio.util.Event +import org.oxycblt.auxio.util.MutableEvent +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW + +/** + * A [ViewModel] that orchestrates menu dialogs and selection state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@HiltViewModel +class ListViewModel +@Inject +constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _selected = MutableStateFlow(listOf()) + /** The currently selected items. These are ordered in earliest selected and latest selected. */ + val selected: StateFlow> + get() = _selected + + private val _menu = MutableEvent

() + /** + * A [Menu] command that is awaiting a view capable of responding to it. Null if none currently. + */ + val menu: Event = _menu + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + // Sanitize the selection to remove items that no longer exist and thus + // won't appear in any list. + _selected.value = + _selected.value.mapNotNull { + when (it) { + is Song -> deviceLibrary.findSong(it.uid) + is Album -> deviceLibrary.findAlbum(it.uid) + is Artist -> deviceLibrary.findArtist(it.uid) + is Genre -> deviceLibrary.findGenre(it.uid) + is Playlist -> userLibrary.findPlaylist(it.uid) + } + } + } + + override fun onCleared() { + super.onCleared() + musicRepository.removeUpdateListener(this) + } + + /** + * Select a new [Music] item. If this item is already within the selected items, the item will + * be removed. Otherwise, it will be added. + * + * @param music The [Music] item to select. + */ + fun select(music: Music) { + if (music is MusicParent && music.songs.isEmpty()) { + logD("Cannot select empty parent, ignoring operation") + return + } + + val selected = _selected.value.toMutableList() + if (!selected.remove(music)) { + logD("Adding $music to selection") + selected.add(music) + } else { + logD("Removed $music from selection") + } + + _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. + * + * @return A list of [Song]s collated from each item selected. + */ + fun takeSelection(): List { + logD("Taking selection") + return peekSelection().also { _selected.value = listOf() } + } + + /** + * Clear the current selection. + * + * @return true if the prior selection was non-empty, false otherwise. + */ + fun dropSelection(): Boolean { + logD("Dropping selection [empty=${_selected.value.isEmpty()}]") + return _selected.value.isNotEmpty().also { _selected.value = listOf() } + } + + /** + * Open a menu for a [Song]. 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 song The [Song] to show. + * @param playWith A [PlaySong] command to give context to what "Play" and "Shuffle" actions + * should do. + */ + fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) { + logD("Opening menu for $song") + openImpl(Menu.ForSong(menuRes, song, playWith)) + } + + /** + * Open a menu for a [Album]. 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 album The [Album] to show. + */ + fun openMenu(@MenuRes menuRes: Int, album: Album) { + logD("Opening menu for $album") + openImpl(Menu.ForAlbum(menuRes, album)) + } + + /** + * Open a menu for a [Artist]. 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 artist The [Artist] to show. + */ + fun openMenu(@MenuRes menuRes: Int, artist: Artist) { + logD("Opening menu for $artist") + openImpl(Menu.ForArtist(menuRes, artist)) + } + + /** + * Open a menu for a [Genre]. 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 genre The [Genre] to show. + */ + fun openMenu(@MenuRes menuRes: Int, genre: Genre) { + logD("Opening menu for $genre") + openImpl(Menu.ForGenre(menuRes, genre)) + } + + /** + * Open a menu for a [Playlist]. 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 playlist The [Playlist] to show. + */ + fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) { + logD("Opening menu for $playlist") + 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) { + logW("Already opening $existing, ignoring $menu") + return + } + _menu.put(menu) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt index d728a6142..c7704e503 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt @@ -115,9 +115,8 @@ interface SelectableListListener : ClickableListListener { * Called when an item in the list requests that a menu related to it should be opened. * * @param item The [T] item to open a menu for. - * @param anchor The [View] to anchor the menu to. */ - fun onOpenMenu(item: T, anchor: View) + fun onOpenMenu(item: T) /** * Called when an item in the list requests that it be selected. @@ -148,6 +147,6 @@ interface SelectableListListener : ClickableListListener { true } // Map the menu button to the menu opening listener. - menuButton.setOnClickListener { onOpenMenu(item, it) } + menuButton.setOnClickListener { onOpenMenu(item) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt similarity index 71% rename from app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt rename to app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt index 88bdba6d8..69b58ac5f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.list.selection +package org.oxycblt.auxio.list import android.os.Bundle import android.view.MenuItem @@ -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 /** @@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.showToast */ abstract class SelectionFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener { - protected abstract val selectionModel: SelectionViewModel + protected abstract val listModel: ListViewModel protected abstract val musicModel: MusicViewModel protected abstract val playbackModel: PlaybackViewModel @@ -46,8 +46,11 @@ abstract class SelectionFragment : super.onBindingCreated(binding, savedInstanceState) getSelectionToolbar(binding)?.apply { // Add cancel and menu item listeners to manage what occurs with the selection. - setNavigationOnClickListener { selectionModel.drop() } + setNavigationOnClickListener { listModel.dropSelection() } setOnMenuItemClickListener(this@SelectionFragment) + overrideOnOverflowMenuClick { + listModel.openMenu(R.menu.selection, listModel.peekSelection()) + } } } @@ -59,31 +62,16 @@ abstract class SelectionFragment : override fun onMenuItemClick(item: MenuItem) = when (item.itemId) { R.id.action_selection_play_next -> { - playbackModel.playNext(selectionModel.take()) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_selection_queue_add -> { - playbackModel.addToQueue(selectionModel.take()) + playbackModel.playNext(listModel.takeSelection()) requireContext().showToast(R.string.lng_queue_added) true } R.id.action_selection_playlist_add -> { - musicModel.addToPlaylist(selectionModel.take()) - true - } - R.id.action_selection_play -> { - playbackModel.play(selectionModel.take()) - true - } - R.id.action_selection_shuffle -> { - playbackModel.shuffle(selectionModel.take()) - true - } - R.id.action_selection_share -> { - requireContext().share(selectionModel.take()) + musicModel.addToPlaylist(listModel.takeSelection()) true } else -> false } + + // TODO: Re-add the automatic selection handling } diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt index 977c367c4..f01b47f41 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/FlexibleListAdapter.kt @@ -37,6 +37,7 @@ abstract class FlexibleListAdapter( diffCallback: DiffUtil.ItemCallback ) : RecyclerView.Adapter() { @Suppress("LeakingThis") private val differ = FlexibleListDiffer(this, diffCallback) + final override fun getItemCount() = differ.currentList.size /** The current list stored by the adapter's differ instance. */ val currentList: List @@ -69,7 +70,7 @@ abstract class FlexibleListAdapter( */ sealed interface UpdateInstructions { /** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */ - object Diff : UpdateInstructions + data object Diff : UpdateInstructions /** * Visually replace all items from a given point. More visually coherent than [Diff]. @@ -118,6 +119,7 @@ private class FlexibleListDiffer( private class MainThreadExecutor : Executor { val mHandler = Handler(Looper.getMainLooper()) + override fun execute(command: Runnable) { mHandler.post(command) } 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 new file mode 100644 index 000000000..24581b5d2 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 Auxio Project + * Menu.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.list.menu + +import android.os.Parcelable +import androidx.annotation.MenuRes +import kotlinx.parcelize.Parcelize +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.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaySong + +/** + * Command to navigate to a specific menu dialog configuration. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface Menu { + /** The menu resource to inflate in the menu dialog. */ + @get:MenuRes val res: Int + /** A [Parcel] version of this instance that can be used as a navigation argument. */ + val parcel: Parcel + + sealed interface Parcel : Parcelable + + /** Navigate to a [Song] menu dialog. */ + class ForSong(@MenuRes override val res: Int, val song: Song, val playWith: PlaySong) : Menu { + override val parcel: Parcel + get() { + val playWithUid = + when (playWith) { + is PlaySong.FromArtist -> playWith.which?.uid + is PlaySong.FromGenre -> playWith.which?.uid + is PlaySong.FromPlaylist -> playWith.which.uid + is PlaySong.FromAll, + is PlaySong.FromAlbum, + is PlaySong.ByItself -> null + } + + return Parcel(res, song.uid, playWith.intCode, playWithUid) + } + + @Parcelize + data class Parcel( + val res: Int, + val songUid: Music.UID, + val playWithCode: Int, + val playWithUid: Music.UID? + ) : Menu.Parcel + } + + /** Navigate to a [Album] menu dialog. */ + class ForAlbum(@MenuRes override val res: Int, val album: Album) : Menu { + override val parcel + get() = Parcel(res, album.uid) + + @Parcelize data class Parcel(val res: Int, val albumUid: Music.UID) : Menu.Parcel + } + + /** Navigate to a [Artist] menu dialog. */ + class ForArtist(@MenuRes override val res: Int, val artist: Artist) : Menu { + override val parcel + get() = Parcel(res, artist.uid) + + @Parcelize data class Parcel(val res: Int, val artistUid: Music.UID) : Menu.Parcel + } + + /** Navigate to a [Genre] menu dialog. */ + class ForGenre(@MenuRes override val res: Int, val genre: Genre) : Menu { + override val parcel + get() = Parcel(res, genre.uid) + + @Parcelize data class Parcel(val res: Int, val genreUid: Music.UID) : Menu.Parcel + } + + /** Navigate to a [Playlist] menu dialog. */ + class ForPlaylist(@MenuRes override val res: Int, val playlist: Playlist) : Menu { + override val parcel + get() = Parcel(res, playlist.uid) + + @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/MenuDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt new file mode 100644 index 000000000..907cef049 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragment.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuDialogFragment.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.list.menu + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuInflater +import android.view.MenuItem +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.view.children +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.databinding.DialogMenuBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment +import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.logD + +/** + * A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of + * options. + * + * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Extend the amount of music info shown in the dialog + */ +abstract class MenuDialogFragment : + ViewBindingBottomSheetDialogFragment(), ClickableListListener { + protected abstract val menuModel: MenuViewModel + protected abstract val listModel: ListViewModel + private val menuAdapter = MenuItemAdapter(@Suppress("LeakingThis") this) + + abstract val parcel: Menu.Parcel + + /** + * Get the options to disable in the context of the currently shown [M]. + * + * @param menu The currently-shown menu [M]. + */ + abstract fun getDisabledItemIds(menu: M): Set + + /** + * Update the displayed information about the currently shown [M]. + * + * @param binding The [DialogMenuBinding] to bind information to. + * @param menu The currently-shown menu [M]. + */ + abstract fun updateMenu(binding: DialogMenuBinding, menu: M) + + /** + * Forward the clicked [MenuItem] to it's corresponding handler in another module. + * + * @param item The [MenuItem] that was clicked. + * @param menu The currently-shown menu [M]. + */ + abstract fun onClick(item: MenuItem, menu: M) + + override fun onCreateBinding(inflater: LayoutInflater) = DialogMenuBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogMenuBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.menuName.isSelected = true + binding.menuInfo.isSelected = true + binding.menuOptionRecycler.apply { + adapter = menuAdapter + itemAnimator = null + } + + // --- VIEWMODEL SETUP --- + listModel.menu.consume() + menuModel.setMenu(parcel) + collectImmediately(menuModel.currentMenu, this::updateMenu) + } + + override fun onDestroyBinding(binding: DialogMenuBinding) { + super.onDestroyBinding(binding) + binding.menuName.isSelected = false + binding.menuInfo.isSelected = false + binding.menuOptionRecycler.adapter = null + } + + private fun updateMenu(menu: Menu?) { + if (menu == null) { + logD("No menu to show, navigating away") + findNavController().navigateUp() + return + } + + @Suppress("UNCHECKED_CAST") val casted = menu as? M + check(casted != null) { "Unexpected menu instance ${menu::class.simpleName}" } + + // We need to inflate the menu on every menu update since it might have changed + // what options are available (ex. if an artist with no songs has had new songs added). + // Since we don't have (and don't want) a dummy view to inflate this menu, just + // depend on the AndroidX Toolbar internal API and hope for the best. + @SuppressLint("RestrictedApi") val builder = MenuBuilder(requireContext()) + MenuInflater(requireContext()).inflate(casted.res, builder) + + // Disable any menu options as specified by the impl + val disabledIds = getDisabledItemIds(casted) + val visible = + builder.children.mapTo(mutableListOf()) { + it.isEnabled = !disabledIds.contains(it.itemId) + it + } + menuAdapter.update(visible, UpdateInstructions.Diff) + + // Delegate to impl how to show music + updateMenu(requireBinding(), casted) + } + + final override fun onClick(item: MenuItem, viewHolder: RecyclerView.ViewHolder) { + // All option selections close the dialog currently. + // TODO: This should change if the app is 100% migrated to menu dialogs + findNavController().navigateUp() + // Delegate to impl on how to handle items + @Suppress("UNCHECKED_CAST") onClick(item, menuModel.currentMenu.value as M) + } +} 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 new file mode 100644 index 000000000..a7fadce1f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -0,0 +1,376 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuDialogFragmentImpl.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.list.menu + +import android.view.MenuItem +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogMenuBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicViewModel +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 + +/** + * [MenuDialogFragment] implementation for a [Song]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class SongMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: SongMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + // Nothing to disable in song menus. + override fun getDisabledItemIds(menu: Menu.ForSong) = setOf() + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForSong) { + val context = requireContext() + binding.menuCover.bind(menu.song) + binding.menuType.text = getString(R.string.lbl_song) + binding.menuName.text = menu.song.name.resolve(context) + binding.menuInfo.text = menu.song.artists.resolveNames(context) + } + + override fun onClick(item: MenuItem, menu: Menu.ForSong) { + when (item.itemId) { + R.id.action_play -> playbackModel.playExplicit(menu.song, menu.playWith) + R.id.action_shuffle -> playbackModel.shuffleExplicit(menu.song, menu.playWith) + R.id.action_play_next -> { + playbackModel.playNext(menu.song) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + 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_detail -> detailModel.showSong(menu.song) + else -> error("Unexpected menu item selected $item") + } + } +} + +/** + * [MenuDialogFragment] implementation for a [AlbumMenuDialogFragment]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class AlbumMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by viewModels() + override val listModel: ListViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: AlbumMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + // Nothing to disable in album menus. + override fun getDisabledItemIds(menu: Menu.ForAlbum) = setOf() + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) { + val context = requireContext() + binding.menuCover.bind(menu.album) + binding.menuType.text = getString(menu.album.releaseType.stringRes) + binding.menuName.text = menu.album.name.resolve(context) + binding.menuInfo.text = menu.album.artists.resolveNames(context) + } + + override fun onClick(item: MenuItem, menu: Menu.ForAlbum) { + when (item.itemId) { + R.id.action_play -> playbackModel.play(menu.album) + R.id.action_shuffle -> playbackModel.shuffle(menu.album) + R.id.action_detail -> detailModel.showAlbum(menu.album) + R.id.action_play_next -> { + playbackModel.playNext(menu.album) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + playbackModel.addToQueue(menu.album) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_artist_details -> detailModel.showArtist(menu.album) + R.id.action_playlist_add -> musicModel.addToPlaylist(menu.album) + R.id.action_share -> requireContext().share(menu.album) + else -> error("Unexpected menu item selected $item") + } + } +} + +/** + * [MenuDialogFragment] implementation for a [Artist]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class ArtistMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by viewModels() + override val listModel: ListViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: ArtistMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + override fun getDisabledItemIds(menu: Menu.ForArtist) = + if (menu.artist.songs.isEmpty()) { + // Disable any operations that require some kind of songs to work with, as there won't + // be any in an empty artist. + setOf( + R.id.action_play, + R.id.action_shuffle, + R.id.action_play_next, + R.id.action_queue_add, + R.id.action_playlist_add, + R.id.action_share) + } else { + setOf() + } + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForArtist) { + val context = requireContext() + binding.menuCover.bind(menu.artist) + binding.menuType.text = getString(R.string.lbl_artist) + binding.menuName.text = menu.artist.name.resolve(context) + binding.menuInfo.text = + getString( + R.string.fmt_two, + if (menu.artist.explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (menu.artist.songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size) + } else { + getString(R.string.def_song_count) + }) + } + + override fun onClick(item: MenuItem, menu: Menu.ForArtist) { + when (item.itemId) { + R.id.action_play -> playbackModel.play(menu.artist) + R.id.action_shuffle -> playbackModel.shuffle(menu.artist) + R.id.action_detail -> detailModel.showArtist(menu.artist) + R.id.action_play_next -> { + playbackModel.playNext(menu.artist) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + playbackModel.addToQueue(menu.artist) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_playlist_add -> musicModel.addToPlaylist(menu.artist) + R.id.action_share -> requireContext().share(menu.artist) + else -> error("Unexpected menu item $item") + } + } +} + +/** + * [MenuDialogFragment] implementation for a [Genre]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class GenreMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by viewModels() + override val listModel: ListViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: GenreMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + override fun getDisabledItemIds(menu: Menu.ForGenre) = setOf() + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForGenre) { + val context = requireContext() + binding.menuCover.bind(menu.genre) + binding.menuType.text = getString(R.string.lbl_genre) + binding.menuName.text = menu.genre.name.resolve(context) + binding.menuInfo.text = + getString( + R.string.fmt_two, + context.getPlural(R.plurals.fmt_artist_count, menu.genre.artists.size), + context.getPlural(R.plurals.fmt_song_count, menu.genre.songs.size)) + } + + override fun onClick(item: MenuItem, menu: Menu.ForGenre) { + when (item.itemId) { + R.id.action_play -> playbackModel.play(menu.genre) + R.id.action_shuffle -> playbackModel.shuffle(menu.genre) + R.id.action_detail -> detailModel.showGenre(menu.genre) + R.id.action_play_next -> { + playbackModel.playNext(menu.genre) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + playbackModel.addToQueue(menu.genre) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_playlist_add -> musicModel.addToPlaylist(menu.genre) + R.id.action_share -> requireContext().share(menu.genre) + else -> error("Unexpected menu item $item") + } + } +} + +/** + * [MenuDialogFragment] implementation for a [Playlist]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class PlaylistMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by viewModels() + override val listModel: ListViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: PlaylistMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + override fun getDisabledItemIds(menu: Menu.ForPlaylist) = + if (menu.playlist.songs.isEmpty()) { + // Disable any operations that require some kind of songs to work with, as there won't + // be any in an empty playlist. + setOf( + R.id.action_play, + R.id.action_shuffle, + R.id.action_play_next, + R.id.action_queue_add, + R.id.action_playlist_add, + R.id.action_share) + } else { + setOf() + } + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForPlaylist) { + val context = requireContext() + binding.menuCover.bind(menu.playlist) + binding.menuType.text = getString(R.string.lbl_playlist) + binding.menuName.text = menu.playlist.name.resolve(context) + binding.menuInfo.text = + if (menu.playlist.songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, menu.playlist.songs.size) + } else { + getString(R.string.def_song_count) + } + } + + override fun onClick(item: MenuItem, menu: Menu.ForPlaylist) { + when (item.itemId) { + R.id.action_play -> playbackModel.play(menu.playlist) + R.id.action_shuffle -> playbackModel.shuffle(menu.playlist) + R.id.action_detail -> detailModel.showPlaylist(menu.playlist) + R.id.action_play_next -> { + playbackModel.playNext(menu.playlist) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + playbackModel.addToQueue(menu.playlist) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_rename -> musicModel.renamePlaylist(menu.playlist) + R.id.action_delete -> musicModel.deletePlaylist(menu.playlist) + R.id.action_share -> requireContext().share(menu.playlist) + else -> error("Unexpected menu item $item") + } + } +} + +/** + * [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/MenuItemAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuItemAdapter.kt new file mode 100644 index 000000000..d5b5158f4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuItemAdapter.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuItemAdapter.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.list.menu + +import android.view.MenuItem +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import org.oxycblt.auxio.databinding.ItemMenuOptionBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.inflater + +/** + * Displays a list of [MenuItem]s as custom list items. + * + * @param listener A [ClickableListListener] to bind interactions to. + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuItemAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter(MenuItemViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + MenuItemViewHolder.from(parent) + + override fun onBindViewHolder(holder: MenuItemViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a [MenuItem]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class MenuItemViewHolder private constructor(private val binding: ItemMenuOptionBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param item The new [MenuItem] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + fun bind(item: MenuItem, listener: ClickableListListener) { + listener.bind(item, this) + binding.title.apply { + text = item.title + setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, null, null, null) + isEnabled = item.isEnabled + } + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup) = + MenuItemViewHolder(ItemMenuOptionBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem == newItem + + override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) = + oldItem.title == newItem.title + } + } +} 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 new file mode 100644 index 000000000..18ff75ccc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 Auxio Project + * MenuViewModel.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.list.menu + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.playback.PlaySong +import org.oxycblt.auxio.util.logW + +/** + * Manages the state information for [MenuDialogFragment] implementations. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@HiltViewModel +class MenuViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener { + private val _currentMenu = MutableStateFlow(null) + /** The current [Menu] information being shown in a dialog. */ + val currentMenu: StateFlow = _currentMenu + + init { + musicRepository.addUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + _currentMenu.value = _currentMenu.value?.let { unpackParcel(it.parcel) } + } + + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + fun setMenu(parcel: Menu.Parcel) { + _currentMenu.value = unpackParcel(parcel) + if (_currentMenu.value == null) { + logW("Given menu parcel $parcel was invalid") + } + } + + private fun unpackParcel(parcel: Menu.Parcel) = + when (parcel) { + is Menu.ForSong.Parcel -> unpackSongParcel(parcel) + is Menu.ForAlbum.Parcel -> unpackAlbumParcel(parcel) + 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? { + val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null + val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent? + val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null + return Menu.ForSong(parcel.res, song, playWith) + } + + private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? { + val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null + return Menu.ForAlbum(parcel.res, album) + } + + private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? { + val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null + return Menu.ForArtist(parcel.res, artist) + } + + private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? { + val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null + return Menu.ForGenre(parcel.res, genre) + } + + private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? { + 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/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index c829248e5..36565b63f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -164,7 +164,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin binding.parentInfo.text = binding.context.getString( R.string.fmt_two, - binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size), + if (artist.explicitAlbums.isNotEmpty()) { + binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size) + } else { + binding.context.getString(R.string.def_album_count) + }, if (artist.songs.isNotEmpty()) { binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size) } else { @@ -199,7 +203,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.name == newItem.name && - oldItem.albums.size == newItem.albums.size && + oldItem.explicitAlbums.size == newItem.explicitAlbums.size && oldItem.songs.size == newItem.songs.size } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt deleted file mode 100644 index 3f4efdd0e..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * SelectionViewModel.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.list.selection - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -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.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.logD - -/** - * A [ViewModel] that manages the current selection. - * - * @author Alexander Capehart (OxygenCobalt) - */ -@HiltViewModel -class SelectionViewModel -@Inject -constructor( - private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings -) : ViewModel(), MusicRepository.UpdateListener { - private val _selected = MutableStateFlow(listOf()) - /** the currently selected items. These are ordered in earliest selected and latest selected. */ - val selected: StateFlow> - get() = _selected - - init { - musicRepository.addUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val userLibrary = musicRepository.userLibrary ?: return - // Sanitize the selection to remove items that no longer exist and thus - // won't appear in any list. - _selected.value = - _selected.value.mapNotNull { - when (it) { - is Song -> deviceLibrary.findSong(it.uid) - is Album -> deviceLibrary.findAlbum(it.uid) - is Artist -> deviceLibrary.findArtist(it.uid) - is Genre -> deviceLibrary.findGenre(it.uid) - is Playlist -> userLibrary.findPlaylist(it.uid) - } - } - } - - override fun onCleared() { - super.onCleared() - musicRepository.removeUpdateListener(this) - } - - /** - * Select a new [Music] item. If this item is already within the selected items, the item will - * be removed. Otherwise, it will be added. - * - * @param music The [Music] item to select. - */ - fun select(music: Music) { - if (music is MusicParent && music.songs.isEmpty()) { - logD("Cannot select empty parent, ignoring operation") - return - } - - val selected = _selected.value.toMutableList() - if (!selected.remove(music)) { - logD("Adding $music to selection") - selected.add(music) - } else { - logD("Removed $music from selection") - } - - _selected.value = selected - } - - /** - * Clear the current selection and return it. - * - * @return A list of [Song]s collated from each item selected. - */ - fun take(): List { - logD("Taking selection") - return _selected.value - .flatMap { - when (it) { - is Song -> listOf(it) - is Album -> musicSettings.albumSongSort.songs(it.songs) - is Artist -> musicSettings.artistSongSort.songs(it.songs) - is Genre -> musicSettings.genreSongSort.songs(it.songs) - is Playlist -> it.songs - } - } - .also { _selected.value = listOf() } - } - - /** - * Clear the current selection. - * - * @return true if the prior selection was non-empty, false otherwise. - */ - fun drop(): Boolean { - logD("Dropping selection [empty=${_selected.value.isEmpty()}]") - return _selected.value.isNotEmpty().also { _selected.value = listOf() } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/sort/Sort.kt similarity index 88% rename from app/src/main/java/org/oxycblt/auxio/list/Sort.kt rename to app/src/main/java/org/oxycblt/auxio/list/sort/Sort.kt index 8a7203182..d4088358a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/sort/Sort.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Auxio Project + * Copyright (c) 2023 Auxio Project * Sort.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify @@ -16,9 +16,8 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.list +package org.oxycblt.auxio.list.sort -import androidx.annotation.IdRes import kotlin.math.max import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -26,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.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Date @@ -42,22 +40,6 @@ import org.oxycblt.auxio.music.info.Disc * @author Alexander Capehart (OxygenCobalt) */ data class Sort(val mode: Mode, val direction: Direction) { - /** - * Create a new [Sort] with the same [mode], but a different [Direction]. - * - * @param direction The new [Direction] to sort in. - * @return A new sort with the same mode, but with the new [Direction] value applied. - */ - fun withDirection(direction: Direction) = Sort(mode, direction) - - /** - * Create a new [Sort] with the same [direction] value, but different [mode] value. - * - * @param mode Tbe new mode to use for the Sort. - * @return A new sort with the same [direction] value, but with the new [mode] applied. - */ - fun withMode(mode: Mode) = Sort(mode, direction) - /** * Sort a list of [Song]s. * @@ -163,8 +145,8 @@ data class Sort(val mode: Mode, val direction: Direction) { sealed interface Mode { /** The integer representation of this sort mode. */ val intCode: Int - /** The item ID of this sort mode in menu resources. */ - val itemId: Int + /** The string resource of the human-readable name of this sort mode. */ + val stringRes: Int /** * Get a [Comparator] that sorts [Song]s according to this [Mode]. @@ -216,12 +198,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Music.name */ - object ByName : Mode { + data object ByName : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_NAME - override val itemId: Int - get() = R.id.option_sort_name + override val stringRes: Int + get() = R.string.lbl_name override fun getSongComparator(direction: Direction) = compareByDynamic(direction, BasicComparator.SONG) @@ -244,12 +226,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Album.name */ - object ByAlbum : Mode { + data object ByAlbum : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_ALBUM - override val itemId: Int - get() = R.id.option_sort_album + override val stringRes: Int + get() = R.string.lbl_album override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -264,12 +246,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Artist.name */ - object ByArtist : Mode { + data object ByArtist : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_ARTIST - override val itemId: Int - get() = R.id.option_sort_artist + override val stringRes: Int + get() = R.string.lbl_artist override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -293,12 +275,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * @see Song.date * @see Album.dates */ - object ByDate : Mode { + data object ByDate : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_YEAR - override val itemId: Int - get() = R.id.option_sort_year + override val stringRes: Int + get() = R.string.lbl_date override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -315,12 +297,12 @@ data class Sort(val mode: Mode, val direction: Direction) { } /** Sort by the duration of an item. */ - object ByDuration : Mode { + data object ByDuration : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_DURATION - override val itemId: Int - get() = R.id.option_sort_duration + override val stringRes: Int + get() = R.string.lbl_duration override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -345,17 +327,13 @@ data class Sort(val mode: Mode, val direction: Direction) { compareBy(BasicComparator.PLAYLIST)) } - /** - * Sort by the amount of songs an item contains. Only available for [MusicParent]s. - * - * @see MusicParent.songs - */ - object ByCount : Mode { + /** Sort by the amount of songs an item contains. Only available for MusicParents. */ + data object ByCount : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_COUNT - override val itemId: Int - get() = R.id.option_sort_count + override val stringRes: Int + get() = R.string.lbl_song_count override fun getAlbumComparator(direction: Direction): Comparator = MultiComparator( @@ -381,12 +359,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Song.disc */ - object ByDisc : Mode { + data object ByDisc : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_DISC - override val itemId: Int - get() = R.id.option_sort_disc + override val stringRes: Int + get() = R.string.lbl_disc override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -400,12 +378,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * * @see Song.track */ - object ByTrack : Mode { + data object ByTrack : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_TRACK - override val itemId: Int - get() = R.id.option_sort_track + override val stringRes: Int + get() = R.string.lbl_track override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -420,12 +398,12 @@ data class Sort(val mode: Mode, val direction: Direction) { * @see Song.dateAdded * @see Album.dates */ - object ByDateAdded : Mode { + data object ByDateAdded : Mode { override val intCode: Int get() = IntegerTable.SORT_BY_DATE_ADDED - override val itemId: Int - get() = R.id.option_sort_date_added + override val stringRes: Int + get() = R.string.lbl_date_added override fun getSongComparator(direction: Direction): Comparator = MultiComparator( @@ -458,27 +436,6 @@ data class Sort(val mode: Mode, val direction: Direction) { ByDateAdded.intCode -> ByDateAdded else -> null } - - /** - * Convert a menu item ID into a [Mode]. - * - * @param itemId The menu resource ID to convert - * @return A [Mode] corresponding to the given ID, or null if the ID is invalid. - * @see itemId - */ - fun fromItemId(@IdRes itemId: Int) = - when (itemId) { - ByName.itemId -> ByName - ByAlbum.itemId -> ByAlbum - ByArtist.itemId -> ByArtist - ByDate.itemId -> ByDate - ByDuration.itemId -> ByDuration - ByCount.itemId -> ByCount - ByDisc.itemId -> ByDisc - ByTrack.itemId -> ByTrack - ByDateAdded.itemId -> ByDateAdded - else -> null - } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/sort/SortDialog.kt b/app/src/main/java/org/oxycblt/auxio/list/sort/SortDialog.kt new file mode 100644 index 000000000..8bdb31924 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/sort/SortDialog.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 Auxio Project + * SortDialog.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.list.sort + +import android.os.Bundle +import android.view.LayoutInflater +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButtonToggleGroup +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogSortBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment +import org.oxycblt.auxio.util.systemBarInsetsCompat + +abstract class SortDialog : + ViewBindingBottomSheetDialogFragment(), + ClickableListListener, + MaterialButtonToggleGroup.OnButtonCheckedListener { + private val modeAdapter = SortModeAdapter(@Suppress("LeakingThis") this) + + abstract fun getInitialSort(): Sort? + + abstract fun applyChosenSort(sort: Sort) + + abstract fun getModeChoices(): List + + override fun onCreateBinding(inflater: LayoutInflater) = DialogSortBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + // --- UI SETUP --- + binding.root.setOnApplyWindowInsetsListener { v, insets -> + v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom) + insets + } + binding.sortModeRecycler.adapter = modeAdapter + binding.sortDirectionGroup.addOnButtonCheckedListener(this) + binding.sortCancel.setOnClickListener { dismiss() } + binding.sortSave.setOnClickListener { + applyChosenSort(requireNotNull(getCurrentSort())) + dismiss() + } + + // --- STATE SETUP --- + modeAdapter.update(getModeChoices(), UpdateInstructions.Diff) + + val initial = getInitialSort() + if (initial != null) { + modeAdapter.setSelected(initial.mode) + val directionId = + when (initial.direction) { + Sort.Direction.ASCENDING -> R.id.sort_direction_asc + Sort.Direction.DESCENDING -> R.id.sort_direction_dsc + } + binding.sortDirectionGroup.check(directionId) + } + updateButtons() + } + + override fun onDestroyBinding(binding: DialogSortBinding) { + super.onDestroyBinding(binding) + binding.sortDirectionGroup.removeOnButtonCheckedListener(this) + } + + override fun onClick(item: Sort.Mode, viewHolder: RecyclerView.ViewHolder) { + modeAdapter.setSelected(item) + updateButtons() + } + + override fun onButtonChecked( + group: MaterialButtonToggleGroup?, + checkedId: Int, + isChecked: Boolean + ) { + updateButtons() + } + + private fun updateButtons() { + val binding = requireBinding() + binding.sortSave.isEnabled = getCurrentSort() != getInitialSort() + } + + private fun getCurrentSort(): Sort? { + val initial = getInitialSort() + val mode = modeAdapter.currentMode ?: initial?.mode ?: return null + val direction = + when (requireBinding().sortDirectionGroup.checkedButtonId) { + R.id.sort_direction_asc -> Sort.Direction.ASCENDING + R.id.sort_direction_dsc -> Sort.Direction.DESCENDING + else -> initial?.direction ?: return null + } + return Sort(mode, direction) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/sort/SortModeAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/sort/SortModeAdapter.kt new file mode 100644 index 000000000..25a8d2c69 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/sort/SortModeAdapter.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2023 Auxio Project + * SortModeAdapter.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.list.sort + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import org.oxycblt.auxio.databinding.ItemSortModeBinding +import org.oxycblt.auxio.list.ClickableListListener +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.list.recycler.DialogRecyclerView +import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.util.inflater + +/** + * A [FlexibleListAdapter] that displays a list of [Sort.Mode]s. + * + * @param listener A [ClickableListListener] to bind interactions to. + * @author Alexander Capehart (OxygenCobalt) + */ +class SortModeAdapter(private val listener: ClickableListListener) : + FlexibleListAdapter(SortModeViewHolder.DIFF_CALLBACK) { + /** The currently selected [Sort.Mode] item in this adapter. */ + var currentMode: Sort.Mode? = null + private set + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + SortModeViewHolder.from(parent) + + override fun onBindViewHolder(holder: SortModeViewHolder, position: Int) { + throw NotImplementedError() + } + + override fun onBindViewHolder(holder: SortModeViewHolder, position: Int, payload: List) { + val mode = getItem(position) + if (payload.isEmpty()) { + holder.bind(mode, listener) + } + holder.setSelected(mode == currentMode) + } + + /** + * Select a new [Sort.Mode] option, unselecting the prior one. Does nothing if [mode] equals + * [currentMode]. + * + * @param mode The new [Sort.Mode] to select. Should be in the adapter data. + */ + fun setSelected(mode: Sort.Mode) { + if (mode == currentMode) return + val oldMode = currentList.indexOf(currentMode) + val newMode = currentList.indexOf(mode) + currentMode = mode + if (oldMode > -1) { + notifyItemChanged(oldMode, PAYLOAD_SELECTION_CHANGED) + } + notifyItemChanged(newMode, PAYLOAD_SELECTION_CHANGED) + } + + private companion object { + val PAYLOAD_SELECTION_CHANGED = Any() + } +} + +/** + * A [DialogRecyclerView.ViewHolder] that displays a [Sort.Mode]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class SortModeViewHolder private constructor(private val binding: ItemSortModeBinding) : + DialogRecyclerView.ViewHolder(binding.root) { + /** + * Bind new data to this instance. + * + * @param mode The new [Sort.Mode] to bind. + * @param listener A [ClickableListListener] to bind interactions to. + */ + fun bind(mode: Sort.Mode, listener: ClickableListListener) { + listener.bind(mode, this) + binding.sortRadio.text = binding.context.getString(mode.stringRes) + } + + /** + * Set if this view should be shown as selected or not. + * + * @param selected True if selected, false if not. + */ + fun setSelected(selected: Boolean) { + binding.sortRadio.isChecked = selected + } + + companion object { + fun from(parent: View) = + SortModeViewHolder(ItemSortModeBinding.inflate(parent.context.inflater)) + + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Sort.Mode, newItem: Sort.Mode) = + oldItem == newItem + + override fun areContentsTheSame(oldItem: Sort.Mode, newItem: Sort.Mode) = + oldItem == newItem + } + } +} 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 545a5f234..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 } /** @@ -57,7 +57,7 @@ sealed interface IndexingState { */ sealed interface IndexingProgress { /** Other work is being done that does not have a defined progress. */ - object Indeterminate : IndexingProgress + data object Indeterminate : IndexingProgress /** * Songs are currently being loaded. diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index fc8a51390..7d6fae73e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -80,23 +80,23 @@ sealed interface Music : Item { class UID private constructor( private val format: Format, - private val mode: MusicMode, + private val type: MusicType, private val uuid: UUID ) : Parcelable { // Cache the hashCode for HashMap efficiency. @IgnoredOnParcel private var hashCode = format.hashCode() init { - hashCode = 31 * hashCode + mode.hashCode() + hashCode = 31 * hashCode + type.hashCode() hashCode = 31 * hashCode + uuid.hashCode() } override fun hashCode() = hashCode override fun equals(other: Any?) = - other is UID && format == other.format && mode == other.mode && uuid == other.uuid + other is UID && format == other.format && type == other.type && uuid == other.uuid - override fun toString() = "${format.namespace}:${mode.intCode.toString(16)}-$uuid" + override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid" /** * Internal marker of [Music.UID] format type. @@ -124,23 +124,23 @@ sealed interface Music : Item { * Creates an Auxio-style [UID] of random composition. Used if there is no * non-subjective, unlikely-to-change metadata of the music. * - * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param type The analogous [MusicType] of the item that created this [UID]. */ - fun auxio(mode: MusicMode): UID { - return UID(Format.AUXIO, mode, UUID.randomUUID()) + fun auxio(type: MusicType): UID { + return UID(Format.AUXIO, type, UUID.randomUUID()) } /** * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, * unlikely-to-change metadata of the music. * - * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param type The analogous [MusicType] of the item that created this [UID]. * @param updates Block to update the [MessageDigest] hash with the metadata of the * item. Make sure the metadata hashed semantically aligns with the format * specification. * @return A new auxio-style [UID]. */ - fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID { + fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID { val digest = MessageDigest.getInstance("SHA-256").run { updates() @@ -170,19 +170,19 @@ sealed interface Music : Item { .or(digest[13].toLong().and(0xFF).shl(16)) .or(digest[14].toLong().and(0xFF).shl(8)) .or(digest[15].toLong().and(0xFF))) - return UID(Format.AUXIO, mode, uuid) + return UID(Format.AUXIO, type, uuid) } /** * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID * extracted from a file. * - * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param type The analogous [MusicType] of the item that created this [UID]. * @param mbid The analogous MusicBrainz ID for this item that was extracted from a * file. * @return A new MusicBrainz-style [UID]. */ - fun musicBrainz(mode: MusicMode, mbid: UUID) = UID(Format.MUSICBRAINZ, mode, mbid) + fun musicBrainz(type: MusicType, mbid: UUID) = UID(Format.MUSICBRAINZ, type, mbid) /** * Convert a [UID]'s string representation back into a concrete [UID] instance. @@ -210,10 +210,10 @@ sealed interface Music : Item { return null } - val mode = - MusicMode.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null + val type = + MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null val uuid = ids[1].toUuidOrNull() ?: return null - return UID(format, mode, uuid) + return UID(format, type, uuid) } } } @@ -317,12 +317,6 @@ interface Album : MusicParent { * @author Alexander Capehart (OxygenCobalt) */ interface Artist : MusicParent { - /** - * All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums]. - * Note that any [Song] credited to this artist will have it's [Album] considered to be - * "indirectly" linked to this [Artist], and thus included in this list. - */ - val albums: Collection /** Albums directly credited to this [Artist] via a "Album Artist" tag. */ val explicitAlbums: Collection /** Albums indirectly credited to this [Artist] via an "Artist" tag. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt index e875dd04b..b7e9d3b0d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt @@ -28,5 +28,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) interface MusicModule { @Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository + @Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings } 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..55cfeaf0a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -36,6 +36,8 @@ import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.MediaStoreExtractor +import org.oxycblt.auxio.music.info.Name +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.auxio.music.user.UserLibrary @@ -223,7 +225,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() @@ -356,6 +359,8 @@ constructor( } private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + // TODO: Find a way to break up this monster of a method, preferably as another class. + val start = System.currentTimeMillis() // Make sure we have permissions before going forward. Theoretically this would be better // done at the UI level, but that intertwines logic and display too much. @@ -365,17 +370,29 @@ constructor( throw NoAudioPermissionException() } + // Obtain configuration information + val constraints = + MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs) + val separators = Separators.from(musicSettings.separators) + val nameFactory = + if (musicSettings.intelligentSorting) { + Name.Known.IntelligentFactory + } else { + Name.Known.SimpleFactory + } + // Begin with querying MediaStore and the music cache. The former is needed for Auxio // to figure out what songs are (probably) on the device, and the latter will be needed // for discovery (described later). These have no shared state, so they are done in // parallel. logD("Starting MediaStore query") emitIndexingProgress(IndexingProgress.Indeterminate) + val mediaStoreQueryJob = worker.scope.async { val query = try { - mediaStoreExtractor.query() + mediaStoreExtractor.query(constraints) } catch (e: Exception) { // Normally, errors in an async call immediately bubble up to the Looper // and crash the app. Thus, we have to wrap any error into a Result @@ -444,7 +461,8 @@ constructor( worker.scope.async(Dispatchers.Default) { val deviceLibrary = try { - deviceLibraryFactory.create(completeSongs, processedSongs) + deviceLibraryFactory.create( + completeSongs, processedSongs, separators, nameFactory) } catch (e: Exception) { processedSongs.close(e) return@async Result.failure(e) @@ -517,7 +535,7 @@ constructor( logD("Awaiting DeviceLibrary creation") val deviceLibrary = deviceLibraryJob.await().getOrThrow() logD("Starting UserLibrary creation") - val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary) + val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory) // Loading process is functionally done, indicate such logD( 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 4274ef4e8..488c98126 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -24,7 +24,6 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.fs.Directory import org.oxycblt.auxio.music.fs.MusicDirectories import org.oxycblt.auxio.settings.Settings @@ -44,26 +43,9 @@ 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 - // TODO: Move sort settings to list module - /** The [Sort] mode used in [Song] lists. */ - var songSort: Sort - /** The [Sort] mode used in [Album] lists. */ - var albumSort: Sort - /** The [Sort] mode used in [Artist] lists. */ - var artistSort: Sort - /** The [Sort] mode used in [Genre] lists. */ - var genreSort: Sort - /** The [Sort] mode used in [Playlist] lists. */ - var playlistSort: Sort - /** The [Sort] mode used in an [Album]'s [Song] list. */ - var albumSongSort: Sort - /** The [Sort] mode used in an [Artist]'s [Song] list. */ - var artistSongSort: Sort - /** The [Sort] mode used in a [Genre]'s [Song] list. */ - var genreSongSort: Sort interface Listener { /** Called when a setting controlling how music is loaded has changed. */ @@ -103,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), "") ?: "" @@ -117,113 +99,6 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context override val intelligentSorting: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true) - override var songSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_songs_sort), value.intCode) - apply() - } - } - - override var albumSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_albums_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_albums_sort), value.intCode) - apply() - } - } - - override var artistSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_artists_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_artists_sort), value.intCode) - apply() - } - } - - override var genreSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_genres_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_genres_sort), value.intCode) - apply() - } - } - - override var playlistSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_playlists_sort), value.intCode) - apply() - } - } - override var albumSongSort: Sort - get() { - var sort = - Sort.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING) - - // Correct legacy album sort modes to Disc - if (sort.mode is Sort.Mode.ByName) { - sort = sort.withMode(Sort.Mode.ByDisc) - } - - return sort - } - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_album_songs_sort), value.intCode) - apply() - } - } - - override var artistSongSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_artist_songs_sort), value.intCode) - apply() - } - } - - override var genreSongSort: Sort - get() = - Sort.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_genre_songs_sort), value.intCode) - apply() - } - } - override fun onSettingChanged(key: String, listener: MusicSettings.Listener) { // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // (just need to manipulate data) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt similarity index 73% rename from app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt rename to app/src/main/java/org/oxycblt/auxio/music/MusicType.kt index 03ec48dae..19f535af1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * MusicMode.kt is part of Auxio. + * MusicType.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 @@ -21,20 +21,20 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.IntegerTable /** - * Represents a data configuration corresponding to a specific type of [Music], + * General configuration enum to control what kind of music is being worked with. * * @author Alexander Capehart (OxygenCobalt) */ -enum class MusicMode { - /** Configure with respect to [Song] instances. */ +enum class MusicType { + /** @see Song */ SONGS, - /** Configure with respect to [Album] instances. */ + /** @see Album */ ALBUMS, - /** Configure with respect to [Artist] instances. */ + /** @see Artist */ ARTISTS, - /** Configure with respect to [Genre] instances. */ + /** @see Genre */ GENRES, - /** Configure with respect to [Playlist] instances. */ + /** @see Playlist */ PLAYLISTS; /** @@ -54,11 +54,11 @@ enum class MusicMode { companion object { /** - * Convert a [MusicMode] integer representation into an instance. + * Convert a [MusicType] integer representation into an instance. * - * @param intCode An integer representation of a [MusicMode] - * @return The corresponding [MusicMode], or null if the [MusicMode] is invalid. - * @see MusicMode.intCode + * @param intCode An integer representation of a [MusicType] + * @return The corresponding [MusicType], or null if the [MusicType] is invalid. + * @see MusicType.intCode */ fun fromIntCode(intCode: Int) = when (intCode) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 5c2b2b86a..314b38785 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.logD @@ -39,8 +40,8 @@ import org.oxycblt.auxio.util.logD class MusicViewModel @Inject constructor( + private val listSettings: ListSettings, private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { private val _indexingState = MutableStateFlow(null) @@ -53,6 +54,10 @@ constructor( get() = _statistics private val _playlistDecision = MutableEvent() + /** + * A [PlaylistDecision] command that is awaiting a view capable of responding to it. Null if + * none currently. + */ val playlistDecision: Event get() = _playlistDecision @@ -163,7 +168,7 @@ constructor( */ fun addToPlaylist(album: Album, playlist: Playlist? = null) { logD("Adding $album to playlist") - addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist) + addToPlaylist(listSettings.albumSongSort.songs(album.songs), playlist) } /** @@ -174,7 +179,7 @@ constructor( */ fun addToPlaylist(artist: Artist, playlist: Playlist? = null) { logD("Adding $artist to playlist") - addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist) + addToPlaylist(listSettings.artistSongSort.songs(artist.songs), playlist) } /** @@ -185,7 +190,7 @@ constructor( */ fun addToPlaylist(genre: Genre, playlist: Playlist? = null) { logD("Adding $genre to playlist") - addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist) + addToPlaylist(listSettings.genreSongSort.songs(genre.songs), playlist) } /** @@ -222,9 +227,37 @@ constructor( ) } +/** + * Navigation command for when a [Playlist] must have some operation performed on it by the user. + * + * @author Alexander Capehart (OxygenCobalt) + */ sealed interface PlaylistDecision { + /** + * Navigate to a dialog that allows a user to pick a name for a new [Playlist]. + * + * @param songs The [Song]s to contain in the new [Playlist]. + */ data class New(val songs: List) : PlaylistDecision + + /** + * Navigate to a dialog that allows a user to rename an existing [Playlist]. + * + * @param playlist The playlist to act on. + */ data class Rename(val playlist: Playlist) : PlaylistDecision + + /** + * Navigate to a dialog that confirms the deletion of an existing [Playlist]. + * + * @param playlist The playlist to act on. + */ data class Delete(val playlist: Playlist) : PlaylistDecision + + /** + * Navigate to a dialog that allows the user to add [Song]s to a [Playlist]. + * + * @param songs The [Song]s to add to the chosen [Playlist]. + */ data class Add(val songs: List) : PlaylistDecision } 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 7d1ac68d1..9eb52bbc6 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 @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 32, exportSchema = false) +@Database(entities = [CachedSong::class], version = 36, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } @@ -40,7 +40,9 @@ abstract class CacheDatabase : RoomDatabase() { @Dao interface CachedSongsDao { @Query("SELECT * FROM CachedSong") suspend fun readSongs(): List + @Query("DELETE FROM CachedSong") suspend fun nukeSongs() + @Insert suspend fun insertSongs(songs: List) } @@ -61,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/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt index 0b91c65b3..441a9a7fa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt @@ -100,6 +100,7 @@ private class CacheImpl(cachedSongs: List) : Cache { } override var invalidated = false + override fun populate(rawSong: RawSong): Boolean { // For a cached raw song to be used, it must exist within the cache and have matching // addition and modification timestamps. Technically the addition timestamp doesn't diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index d22f9a5e9..51dcfcf6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.os.Bundle import android.view.LayoutInflater @@ -33,9 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.showToast /** @@ -45,7 +46,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class AddToPlaylistDialog : - ViewBindingDialogFragment(), + ViewBindingMaterialDialogFragment(), ClickableListListener, NewPlaylistFooterAdapter.Listener { private val musicModel: MusicViewModel by activityViewModels() @@ -73,6 +74,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) + musicModel.playlistDecision.consume() collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } @@ -89,7 +91,16 @@ class AddToPlaylistDialog : } override fun onNewPlaylist() { - musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) + // 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/picker/DeletePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt index 15d347199..a1683c6b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/DeletePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/DeletePlaylistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.os.Bundle import android.view.LayoutInflater @@ -30,19 +30,19 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist]. + * A [ViewBindingMaterialDialogFragment] that asks the user to confirm the deletion of a [Playlist]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class DeletePlaylistDialog : ViewBindingDialogFragment() { +class DeletePlaylistDialog : ViewBindingMaterialDialogFragment() { private val pickerModel: PlaylistPickerViewModel by viewModels() private val musicModel: MusicViewModel by activityViewModels() // Information about what playlist to name for is initially within the navigation arguments @@ -71,6 +71,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment. */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.os.Bundle import android.view.LayoutInflater @@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class NewPlaylistDialog : ViewBindingDialogFragment() { +class NewPlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments @@ -83,6 +83,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment() binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) } // --- VIEWMODEL SETUP --- + musicModel.playlistDecision.consume() pickerModel.setPendingPlaylist(requireContext(), args.songUids) collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist) collectImmediately(pickerModel.chosenName, ::updateChosenName) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistFooterAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistFooterAdapter.kt index fb7f1a965..2eee93bda 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/NewPlaylistFooterAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/NewPlaylistFooterAdapter.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistChoiceAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistChoiceAdapter.kt index 02a5424e9..b45df2cdf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistChoiceAdapter.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt index 51a9895cb..b21d9e991 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PlaylistPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/PlaylistPickerViewModel.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.content.Context import androidx.lifecycle.ViewModel @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist @@ -260,9 +260,9 @@ sealed interface ChosenName { /** The current name already exists. */ data class AlreadyExists(val prior: String) : ChosenName /** The current name is empty. */ - object Empty : ChosenName + data object Empty : ChosenName /** The current name only consists of whitespace. */ - object Blank : ChosenName + data object Blank : ChosenName } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt similarity index 94% rename from app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt index 20ed39bd5..4e287dc87 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/RenamePlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/RenamePlaylistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.music.decision import android.os.Bundle import android.view.LayoutInflater @@ -31,7 +31,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class RenamePlaylistDialog : ViewBindingDialogFragment() { +class RenamePlaylistDialog : ViewBindingMaterialDialogFragment() { private val musicModel: MusicViewModel by activityViewModels() private val pickerModel: PlaylistPickerViewModel by viewModels() // Information about what playlist to name for is initially within the navigation arguments @@ -74,6 +74,7 @@ class RenamePlaylistDialog : ViewBindingDialogFragment, - processedSongs: Channel + processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl } } -class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) : - DeviceLibrary.Factory { +class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory { override suspend fun create( rawSongs: Channel, - processedSongs: Channel + processedSongs: Channel, + separators: Separators, + nameFactory: Name.Known.Factory ): DeviceLibraryImpl { val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() @@ -127,7 +131,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 +211,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 +247,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) } @@ -253,10 +257,10 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // TODO: Avoid redundant data creation class DeviceLibraryImpl( - override val songs: Set, - override val albums: Set, - override val artists: Set, - override val genres: Set + override val songs: Collection, + override val albums: Collection, + override val artists: Collection, + override val genres: Collection ) : DeviceLibrary { // Use a mapping to make finding information based on it's UID much faster. private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } } @@ -266,14 +270,19 @@ class DeviceLibraryImpl( // All other music is built from songs, so comparison only needs to check songs. override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs + override fun hashCode() = songs.hashCode() + override fun toString() = "DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " + "artists=${artists.size}, genres=${genres.size})" override fun findSong(uid: Music.UID): Song? = songUidMap[uid] + override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid] + override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid] + override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid] override fun findSongForUri(context: Context, uri: Uri) = 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 354f6520f..ec71efbf4 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 @@ -20,13 +20,12 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R import org.oxycblt.auxio.image.extractor.CoverUri -import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.list.sort.Sort 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.MusicMode -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 import org.oxycblt.auxio.music.fs.Path @@ -36,10 +35,10 @@ 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.nonZeroOrNull +import org.oxycblt.auxio.util.positiveOrNull import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.update @@ -48,14 +47,19 @@ 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(MusicMode.SONGS, it) } - ?: Music.UID.auxio(MusicMode.SONGS) { + rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) } + ?: Music.UID.auxio(MusicType.SONGS) { // Song UIDs are based on the raw data without parsing so that they remain // consistent across music setting changes. Parents are not held up to the // same standard since grouping is already inherently linked to settings. @@ -70,65 +74,47 @@ 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 ${rawSong.fileName}: No title" }, + rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } override val date = rawSong.date - override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() + override val uri = + requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" } + .toAudioUri() override val path = Path( - name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" }, - parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" }) + name = + requireNotNull(rawSong.fileName) { + "Invalid raw ${rawSong.fileName}: No display name" + }, + parent = + requireNotNull(rawSong.directory) { + "Invalid raw ${rawSong.fileName}: No parent directory" + }) override val mimeType = MimeType( fromExtension = - requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" }, + requireNotNull(rawSong.extensionMimeType) { + "Invalid raw ${rawSong.fileName}: No mime type" + }, fromFormat = null) - override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } - override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } + override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.fileName}: No size" } + override val durationMs = + requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.fileName}: No duration" } override val replayGainAdjustment = ReplayGainAdjustment( track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) - override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } + override val dateAdded = + requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.fileName}: 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 @@ -141,40 +127,92 @@ 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 + .mapIndexed { i, name -> + RawArtist( + artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + artistSortNames.getOrNull(i)) + } + .distinctBy { it.key } + + val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) + val albumArtistNames = separators.split(rawSong.albumArtistNames) + val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) + val rawAlbumArtists = + albumArtistNames + .mapIndexed { i, name -> + RawArtist( + albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + albumArtistSortNames.getOrNull(i)) + } + .distinctBy { it.key } + + rawAlbum = + RawAlbum( + mediaStoreId = + requireNotNull(rawSong.albumMediaStoreId) { + "Invalid raw ${rawSong.fileName}: No album id" + }, + musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), + name = + requireNotNull(rawSong.albumName) { + "Invalid raw ${rawSong.fileName}: 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.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) } + + hashCode = 31 * hashCode + rawSong.hashCode() + hashCode = 31 * hashCode + 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]. @@ -209,10 +247,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son * @return This instance upcasted to [Song]. */ fun finalize(): Song { - checkNotNull(_album) { "Malformed song: No album" } + checkNotNull(_album) { "Malformed song ${path.name}: No album" } - check(_artists.isNotEmpty()) { "Malformed song: No artists" } - check(_artists.size == rawArtists.size) { "Malformed song: Artist grouping mismatch" } + check(_artists.isNotEmpty()) { "Malformed song ${path.name}: No artists" } + check(_artists.size == rawArtists.size) { + "Malformed song ${path.name}: Artist grouping mismatch" + } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. @@ -222,8 +262,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son _artists[i] = other } - check(_genres.isNotEmpty()) { "Malformed song: No genres" } - check(_genres.size == rawGenres.size) { "Malformed song: Genre grouping mismatch" } + check(_genres.isNotEmpty()) { "Malformed song ${path.name}: No genres" } + check(_genres.size == rawGenres.size) { + "Malformed song ${path.name}: Genre grouping mismatch" + } for (i in _genres.indices) { // Non-destructively reorder the linked genres so that they align with // the genre ordering within the song metadata. @@ -240,26 +282,26 @@ 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 override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. - rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) } - ?: Music.UID.auxio(MusicMode.ALBUMS) { + rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) } + ?: Music.UID.auxio(MusicType.ALBUMS) { // Hash based on only names despite the presence of a date to increase stability. // I don't know if there is any situation where an artist will have two albums with // the exact same name, but if there is, I would love to know. 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) @@ -309,12 +351,21 @@ class AlbumImpl( dateAdded = earliestDateAdded hashCode = 31 * hashCode + rawAlbum.hashCode() + hashCode = 31 * hashCode + 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)" /** @@ -339,9 +390,11 @@ class AlbumImpl( * @return This instance upcasted to [Album]. */ fun finalize(): Album { - check(songs.isNotEmpty()) { "Malformed album: Empty" } - check(_artists.isNotEmpty()) { "Malformed album: No artists" } - check(_artists.size == rawArtists.size) { "Malformed album: Artist grouping mismatch" } + check(songs.isNotEmpty()) { "Malformed album $name: Empty" } + check(_artists.isNotEmpty()) { "Malformed album $name: No artists" } + check(_artists.size == rawArtists.size) { + "Malformed album $name: Artist grouping mismatch" + } for (i in _artists.indices) { // Non-destructively reorder the linked artists so that they align with // the artist ordering within the song metadata. @@ -358,22 +411,24 @@ 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 = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. - rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } - ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } + 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 - override val albums: Set override val explicitAlbums: Set override val implicitAlbums: Set override val durationMs: Long? @@ -399,17 +454,18 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti music.link(this) albumMap[music] = true } - else -> error("Unexpected input music ${music::class.simpleName}") + else -> error("Unexpected input music $music in $name ${music::class.simpleName}") } } songs = distinctSongs - albums = albumMap.keys + val albums = albumMap.keys explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true } implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } - durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() + durationMs = songs.sumOf { it.durationMs }.positiveOrNull() hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } @@ -417,10 +473,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)" @@ -443,7 +502,16 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti * @return This instance upcasted to [Artist]. */ fun finalize(): Artist { - check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" } + // There are valid artist configurations: + // 1. No songs, no implicit albums, some explicit albums + // 2. Some songs, no implicit albums, some explicit albums + // 3. Some songs, some implicit albums, no implicit albums + // 4. Some songs, some implicit albums, some explicit albums + // I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty, + // but I can't be 100% certain. + check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) { + "Malformed artist $name: Empty" + } genres = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) @@ -455,15 +523,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(MusicMode.GENRES) { update(rawGenre.name) } + 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 @@ -487,13 +558,18 @@ class GenreImpl(grouping: Grouping, musicSettings: MusicSett durationMs = totalDuration hashCode = 31 * hashCode + rawGenre.hashCode() + hashCode = 31 * hashCode + 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)" @@ -515,7 +591,7 @@ class GenreImpl(grouping: Grouping, musicSettings: MusicSett * @return This instance upcasted to [Genre]. */ fun finalize(): Genre { - check(songs.isNotEmpty()) { "Malformed genre: Empty" } + check(songs.isNotEmpty()) { "Malformed genre $name: Empty" } return this } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 10c4192bc..828a468da 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -24,12 +24,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import org.oxycblt.auxio.music.MusicSettings @Module @InstallIn(SingletonComponent::class) class FsModule { @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = - MediaStoreExtractor.from(context, musicSettings) + fun mediaStoreExtractor(@ApplicationContext context: Context) = + MediaStoreExtractor.from(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 11a357f45..392103d80 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -29,7 +29,6 @@ import androidx.core.database.getStringOrNull import java.io.File import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.cache.Cache import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.info.Date @@ -50,9 +49,11 @@ interface MediaStoreExtractor { /** * Query the media database. * + * @param constraints Configuration parameter to restrict what music should be ignored when + * querying. * @return A new [Query] returned from the media database. */ - suspend fun query(): Query + suspend fun query(constraints: Constraints): Query /** * Consume the [Cursor] loaded after [query]. @@ -74,52 +75,54 @@ interface MediaStoreExtractor { /** A black-box interface representing a query from the media database. */ interface Query { val projectedTotal: Int + fun moveToNext(): Boolean + fun close() + fun populateFileInfo(rawSong: RawSong) + fun populateTags(rawSong: RawSong) } + data class Constraints(val excludeNonMusic: Boolean, val musicDirs: MusicDirectories) + companion object { /** * Create a framework-backed instance. * * @param context [Context] required. - * @param musicSettings [MusicSettings] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = + fun from(context: Context): MediaStoreExtractor = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - Api30MediaStoreExtractor(context, musicSettings) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29MediaStoreExtractor(context, musicSettings) - else -> Api21MediaStoreExtractor(context, musicSettings) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) + else -> Api21MediaStoreExtractor(context) } } } -private abstract class BaseMediaStoreExtractor( - protected val context: Context, - private val musicSettings: MusicSettings -) : MediaStoreExtractor { - final override suspend fun query(): MediaStoreExtractor.Query { +private abstract class BaseMediaStoreExtractor(protected val context: Context) : + MediaStoreExtractor { + final override suspend fun query( + constraints: MediaStoreExtractor.Constraints + ): MediaStoreExtractor.Query { val start = System.currentTimeMillis() val args = mutableListOf() var selector = BASE_SELECTOR // Filter out audio that is not music, if enabled. - if (musicSettings.excludeNonMusic) { + if (constraints.excludeNonMusic) { logD("Excluding non-music") selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" } // Set up the projection to follow the music directory configuration. - val dirs = musicSettings.musicDirs - if (dirs.dirs.isNotEmpty()) { + if (constraints.musicDirs.dirs.isNotEmpty()) { selector += " AND " - if (!dirs.shouldInclude) { + if (!constraints.musicDirs.shouldInclude) { logD("Excluding directories in selector") // Without a NOT, the query will be restricted to the specified paths, resulting // in the "Include" mode. With a NOT, the specified paths will not be included, @@ -130,10 +133,10 @@ private abstract class BaseMediaStoreExtractor( // Specifying the paths to filter is version-specific, delegate to the concrete // implementations. - for (i in dirs.dirs.indices) { - if (addDirToSelector(dirs.dirs[i], args)) { + for (i in constraints.musicDirs.dirs.indices) { + if (addDirToSelector(constraints.musicDirs.dirs[i], args)) { selector += - if (i < dirs.dirs.lastIndex) { + if (i < constraints.musicDirs.dirs.lastIndex) { "$dirSelectorTemplate OR " } else { dirSelectorTemplate @@ -285,7 +288,9 @@ private abstract class BaseMediaStoreExtractor( private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) final override val projectedTotal = cursor.count + final override fun moveToNext() = cursor.moveToNext() + final override fun close() = cursor.close() override fun populateFileInfo(rawSong: RawSong) { @@ -356,8 +361,7 @@ private abstract class BaseMediaStoreExtractor( // Note: The separation between version-specific backends may not be the cleanest. To preserve // speed, we only want to add redundancy on known issues, not with possible issues. -private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseMediaStoreExtractor(context, musicSettings) { +private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -441,10 +445,8 @@ private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private abstract class BaseApi29MediaStoreExtractor( - context: Context, - musicSettings: MusicSettings -) : BaseMediaStoreExtractor(context, musicSettings) { +private abstract class BaseApi29MediaStoreExtractor(context: Context) : + BaseMediaStoreExtractor(context) { override val projection: Array get() = super.projection + @@ -506,8 +508,7 @@ private abstract class BaseApi29MediaStoreExtractor( * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) @@ -524,6 +525,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet storageManager: StorageManager ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + override fun populateTags(rawSong: RawSong) { super.populateTags(rawSong) // This extractor is volume-aware, but does not support the modern track columns. @@ -546,8 +548,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : - BaseApi29MediaStoreExtractor(context, musicSettings) { +private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { override val projection: Array get() = super.projection + diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt index 4ecea1336..bca211f9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MusicDirsDialog.kt @@ -35,7 +35,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -47,7 +47,7 @@ import org.oxycblt.auxio.util.showToast */ @AndroidEntryPoint class MusicDirsDialog : - ViewBindingDialogFragment(), DirectoryAdapter.Listener { + ViewBindingMaterialDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null private var storageManager: StorageManager? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt index 1a057bd94..87eff7081 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt @@ -83,7 +83,7 @@ inline fun ContentResolver.useQuery( ) = safeQuery(uri, projection, selector, args).use(block) /** Album art [MediaStore] database is not a built-in constant, have to define it ourselves. */ -private val EXTERNAL_COVERS_URI = Uri.parse("content://media/external/audio/albumart") +private val externalCoversUri = Uri.parse("content://media/external/audio/albumart") /** * Convert a [MediaStore] Song ID into a [Uri] to it's audio file. @@ -102,21 +102,11 @@ fun Long.toAudioUri() = * @return An external storage image [Uri]. May not exist. * @see ContentUris.withAppendedId */ -fun Long.toCoverUri() = ContentUris.withAppendedId(EXTERNAL_COVERS_URI, this) +fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this) // --- STORAGEMANAGER UTILITIES --- // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles -/** - * Provides the analogous method to [StorageManager.getStorageVolumes] method that is usable from - * API 21 to API 23, in which the [StorageManager] API was hidden and differed greatly. - * - * @see StorageManager.getStorageVolumes - */ -@Suppress("NewApi") -private val SM_API21_GET_VOLUME_LIST_METHOD: Method by - lazyReflectedMethod(StorageManager::class, "getVolumeList") - /** * Provides the analogous method to [StorageVolume.getDirectory] method that is usable from API 21 * to API 23, in which the [StorageVolume] API was hidden and differed greatly. @@ -124,7 +114,7 @@ private val SM_API21_GET_VOLUME_LIST_METHOD: Method by * @see StorageVolume.getDirectory */ @Suppress("NewApi") -private val SV_API21_GET_PATH_METHOD: Method by lazyReflectedMethod(StorageVolume::class, "getPath") +private val svApi21GetPathMethod: Method by lazyReflectedMethod(StorageVolume::class, "getPath") /** * The [StorageVolume] considered the "primary" volume by the system, obtained in a @@ -143,13 +133,7 @@ val StorageManager.primaryStorageVolumeCompat: StorageVolume * @see StorageManager.getStorageVolumes */ val StorageManager.storageVolumesCompat: List - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - storageVolumes.toList() - } else { - @Suppress("UNCHECKED_CAST") - (SM_API21_GET_VOLUME_LIST_METHOD.invoke(this) as Array).toList() - } + get() = storageVolumes.toList() /** * The the absolute path to this [StorageVolume]'s directory within the file-system, in a @@ -166,8 +150,7 @@ val StorageVolume.directoryCompat: String? // Replicate API: Analogous method if mounted, null if not when (stateCompat) { Environment.MEDIA_MOUNTED, - Environment.MEDIA_MOUNTED_READ_ONLY -> - SV_API21_GET_PATH_METHOD.invoke(this) as String + Environment.MEDIA_MOUNTED_READ_ONLY -> svApi21GetPathMethod.invoke(this) as String else -> null } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt index 860b3e315..4eb8969f4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Date.kt @@ -25,7 +25,7 @@ import kotlin.math.max import org.oxycblt.auxio.R import org.oxycblt.auxio.util.inRangeOrNull import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.util.nonZeroOrNull +import org.oxycblt.auxio.util.positiveOrNull /** * An ISO-8601/RFC 3339 Date. @@ -74,8 +74,11 @@ class Date private constructor(private val tokens: List) : Comparable } override fun equals(other: Any?) = other is Date && compareTo(other) == 0 + override fun hashCode() = tokens.hashCode() + override fun toString() = StringBuilder().appendDate().toString() + override fun compareTo(other: Date): Int { for (i in 0 until max(tokens.size, other.tokens.size)) { val ai = tokens.getOrNull(i) @@ -247,7 +250,7 @@ class Date private constructor(private val tokens: List) : Comparable * @param dst The destination list to add valid tokens to. */ private fun transformTokens(src: List, dst: MutableList) { - dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) + dst.add(src.getOrNull(0)?.positiveOrNull() ?: return) dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return) diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index 52b7ab646..2c8fd360b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -29,6 +29,8 @@ import org.oxycblt.auxio.list.Item class Disc(val number: Int, val name: String?) : Item, Comparable { // We don't want to group discs by differing subtitles, so only compare by the number override fun equals(other: Any?) = other is Disc && number == other.number + override fun hashCode() = number.hashCode() + override fun compareTo(other: Disc) = number.compareTo(other.number) } 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 03f3a33f6..30626f01e 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,14 +20,14 @@ 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 /** * The name of a music item. * - * This class automatically implements + * This class automatically implements advanced sorting heuristics for music naming, * * @author Alexander Capehart */ @@ -54,36 +54,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 +79,24 @@ sealed interface Name : Comparable { is Unknown -> 1 } - companion object { + sealed 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 + } + + /** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */ + data object SimpleFactory : Factory { + override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) + } + + /** Produces an intelligent [Known] with advanced, but more fragile heuristics. */ + data object IntelligentFactory : Factory { + override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) } } @@ -132,7 +107,9 @@ sealed interface Name : Comparable { */ data class Unknown(@StringRes val stringRes: Int) : Name { override val thumb = "?" + override fun resolve(context: Context) = context.getString(stringRes) + override fun compareTo(other: Name) = when (other) { // Unknown names do not need any direct comparison right now. @@ -143,22 +120,23 @@ sealed interface Name : Comparable { } } -private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } -private val PUNCT_REGEX by lazy { Regex("[\\p{Punct}+]") } +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() { +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(PUNCT_REGEX, "").ifEmpty { name } - val collationKey = COLLATOR.getCollationKey(stripped) + 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) } @@ -169,7 +147,7 @@ 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?) : +data class IntelligentKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = parseTokens(sort ?: raw) @@ -178,8 +156,9 @@ 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 - .replace(PUNCT_REGEX, "") + // Remove excess punctuation from the string, as those usually aren't + // considered in sorting. + .replace(punctRegex, "") .ifEmpty { name } .run { // Strip any english articles like "the" or "an" from the start, as music @@ -206,10 +185,10 @@ private data class IntelligentKnownName(override val raw: String, override val s val digits = token.trimStart { Character.getNumericValue(it) == 0 }.ifEmpty { token } // Other languages have other types of digit strings, still use collation keys - collationKey = COLLATOR.getCollationKey(digits) + collationKey = collator.getCollationKey(digits) type = SortToken.Type.NUMERIC } else { - collationKey = COLLATOR.getCollationKey(token) + collationKey = collator.getCollationKey(token) type = SortToken.Type.LEXICOGRAPHIC } SortToken(collationKey, type) @@ -220,3 +199,32 @@ private data class IntelligentKnownName(override val raw: String, override val s 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/info/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt index c4cb00fd3..3fe45b202 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/ReleaseType.kt @@ -111,7 +111,7 @@ sealed interface ReleaseType { * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually * visual) media. */ - object Soundtrack : ReleaseType { + data object Soundtrack : ReleaseType { override val refinement: Refinement? get() = null @@ -123,7 +123,7 @@ sealed interface ReleaseType { * A (DJ) Mix. These are usually one large track consisting of the artist playing several * sub-tracks with smooth transitions between them. */ - object Mix : ReleaseType { + data object Mix : ReleaseType { override val refinement: Refinement? get() = null @@ -135,7 +135,7 @@ sealed interface ReleaseType { * A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a * future release. */ - object Mixtape : ReleaseType { + data object Mixtape : ReleaseType { override val refinement: Refinement? get() = null @@ -143,6 +143,18 @@ sealed interface ReleaseType { get() = R.string.lbl_mixtape } + /** + * A demo. These are usually [EP]-sized releases of music made to promote an Artist or a future + * release. + */ + data object Demo : ReleaseType { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_demo + } + /** A specification of what kind of performance a particular release is. */ enum class Refinement { /** A release consisting of a live performance */ @@ -220,6 +232,7 @@ sealed interface ReleaseType { type.equals("dj-mix", true) -> Mix type.equals("live", true) -> convertRefinement(Refinement.LIVE) type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + type.equals("demo", true) -> Demo else -> convertRefinement(null) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt index 96685a746..1f42df505 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -27,6 +27,8 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface MetadataModule { @Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor + @Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory + @Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory } 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..678e1ef2f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -0,0 +1,70 @@ +/* + * 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 + +/** + * 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 a string of separator characters to use. + * + * @param chars The separator characters to use. Each character in the string will be + * checked for when splitting a string list. + * @return A new [Separators] instance reflecting the separators. + */ + 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 3496ea059..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 @@ -29,19 +29,19 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logW /** - * A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to - * split tags with multiple values. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the separator characters + * used to split tags with multiple values. * * @author Alexander Capehart (OxygenCobalt) * * TODO: Replace with unsplit names dialog */ @AndroidEntryPoint -class SeparatorsDialog : ViewBindingDialogFragment() { +class SeparatorsDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = @@ -52,7 +52,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_separators) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - musicSettings.multiValueSeparators = getCurrentSeparators() + musicSettings.separators = getCurrentSeparators() } } @@ -68,8 +68,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { // More efficient to do one iteration through the separator list and initialize // the corresponding CheckBox for each character instead of doing an iteration // through the separator list for each CheckBox. - (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) - ?: musicSettings.multiValueSeparators) + (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: musicSettings.separators) .forEach { when (it) { Separators.COMMA -> binding.separatorComma.isChecked = true @@ -102,14 +101,6 @@ class SeparatorsDialog : ViewBindingDialogFragment() { return separators } - private object Separators { - const val COMMA = ',' - const val SEMICOLON = ';' - const val SLASH = '/' - const val PLUS = '+' - const val AND = '&' - } - private companion object { const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS" } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index c4f20df4a..f7465c73c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -18,29 +18,15 @@ package org.oxycblt.auxio.music.metadata -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.util.nonZeroOrNull +import org.oxycblt.auxio.util.positiveOrNull /// --- GENERIC PARSING --- -/** - * Parse a multi-value tag based on the user configuration. If the value is already composed of more - * than one value, nothing is done. Otherwise, this function will attempt to split it based on the - * user's separator preferences. - * - * @param settings [MusicSettings] required to obtain user separator configuration. - * @return A new list of one or more [String]s. - */ -fun List.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 --- /** @@ -154,7 +129,7 @@ fun parseVorbisPositionField(pos: String?, total: String?) = * - The position was zeroed AND the total value was not present/zeroed */ fun transformPositionField(pos: Int?, total: Int?) = - if (pos != null && (pos > 0 || (total?.nonZeroOrNull() != null))) { + if (pos != null && (pos > 0 || (total?.positiveOrNull() != null))) { pos } else { null @@ -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. @@ -204,14 +178,14 @@ private fun String.parseId3v1Genre(): String? { "RX" -> "Remix" else -> null } - return GENRE_TABLE.getOrNull(numeric) + return genreTable.getOrNull(numeric) } /** * A [Regex] that implements parsing for ID3v2's genre format. Derived from mutagen: * https://github.com/quodlibet/mutagen */ -private val ID3V2_GENRE_RE by lazy { Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") } +private val id3v2GenreRe by lazy { Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") } /** * Parse an ID3v2 integer genre field, which has support for multiple genre values and combined @@ -220,7 +194,7 @@ private val ID3V2_GENRE_RE by lazy { Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?") } * @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre. */ private fun String.parseId3v2Genre(): List? { - val groups = (ID3V2_GENRE_RE.matchEntire(this) ?: return null).groupValues + val groups = (id3v2GenreRe.matchEntire(this) ?: return null).groupValues val genres = mutableSetOf() // ID3v2.3 genres are far more complex and require string grokking to properly implement. @@ -260,7 +234,7 @@ private fun String.parseId3v2Genre(): List? { * A table of the "conventional" mapping between ID3v1 integer genres and their named counterparts. * Includes non-standard extensions. */ -private val GENRE_TABLE = +private val genreTable = arrayOf( // ID3 Standard "Blues", 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/metadata/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index a3d916b69..7e8c87391 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -28,11 +28,9 @@ import androidx.media3.extractor.metadata.vorbis.VorbisComment * * @param metadata The [Metadata] to wrap. * @author Alexander Capehart (OxygenCobalt) - * - * TODO: Merge with TagWorker */ class TextTags(metadata: Metadata) { - private val _id3v2 = mutableMapOf>() + private val _id3v2 = mutableMapOf>() /** The ID3v2 text identification frames found in the file. Can have more than one value. */ val id3v2: Map> get() = _id3v2 @@ -53,7 +51,11 @@ class TextTags(metadata: Metadata) { ?: tag.id.sanitize() val values = tag.values.map { it.sanitize() }.correctWhitespace() if (values.isNotEmpty()) { - _id3v2[id] = values + // Normally, duplicate ID3v2 frames are forbidden. But since MP4 atoms, + // which can also have duplicates, are mapped to ID3v2 frames by ExoPlayer, + // we must drop this invariant and gracefully treat duplicates as if they + // are another way of specfiying multi-value tags. + _id3v2.getOrPut(id) { mutableListOf() }.addAll(values) } } is InternalFrame -> { @@ -62,7 +64,7 @@ class TextTags(metadata: Metadata) { val id = "TXXX:${tag.description.sanitize().lowercase()}" val value = tag.text if (value.isNotEmpty()) { - _id3v2[id] = listOf(value) + _id3v2.getOrPut(id) { mutableListOf() }.add(value) } } is VorbisComment -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt index 301fa1b46..e94e4fe16 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt @@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.newMainPendingIntent * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - ForegroundServiceNotification(context, INDEXER_CHANNEL) { + ForegroundServiceNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -98,7 +98,7 @@ class IndexingNotification(private val context: Context) : * @author Alexander Capehart (OxygenCobalt) */ class ObservingNotification(context: Context) : - ForegroundServiceNotification(context, INDEXER_CHANNEL) { + ForegroundServiceNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -115,6 +115,6 @@ class ObservingNotification(context: Context) : } /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ -private val INDEXER_CHANNEL = +private val indexerChannel = ForegroundServiceNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) 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 9ad14f411..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,8 +19,7 @@ package org.oxycblt.auxio.music.user import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary @@ -42,17 +41,19 @@ private constructor( override fun equals(other: Any?) = other is PlaylistImpl && uid == other.uid && name == other.name && songs == other.songs + override fun hashCode() = hashCode + override fun toString() = "Playlist(uid=$uid, name=$name)" /** * 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. @@ -74,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(MusicMode.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 5e0e7ca55..70943b7d3 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 @@ -22,10 +22,10 @@ import java.lang.Exception import javax.inject.Inject import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository -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 @@ -81,7 +81,8 @@ interface UserLibrary { */ suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary } } @@ -138,13 +139,13 @@ interface MutableUserLibrary : UserLibrary { suspend fun rewritePlaylist(playlist: Playlist, songs: List): Boolean } -class UserLibraryFactoryImpl -@Inject -constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : +class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao) : 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() @@ -152,26 +153,27 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus override suspend fun create( rawPlaylists: List, - deviceLibrary: DeviceLibrary + deviceLibrary: DeviceLibrary, + nameFactory: Name.Known.Factory ): MutableUserLibrary { - logD("Successfully read ${rawPlaylists.size} playlists") - // Convert the database playlist information to actual usable playlists. 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) + return UserLibraryImpl(playlistDao, playlistMap, nameFactory) } } private class UserLibraryImpl( private val playlistDao: PlaylistDao, private val playlistMap: MutableMap, - private val musicSettings: MusicSettings + private val nameFactory: Name.Known.Factory ) : MutableUserLibrary { override fun hashCode() = playlistMap.hashCode() + override fun equals(other: Any?) = other is UserLibraryImpl && other.playlistMap == playlistMap + override fun toString() = "UserLibrary(playlists=${playlists.size})" override val playlists: Collection @@ -182,7 +184,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, nameFactory) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( @@ -205,7 +207,7 @@ 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, nameFactory) } } return try { diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt index 9c46bbe78..b6358ee3b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -51,7 +51,7 @@ abstract class UserMusicDatabase : RoomDatabase() { * @author Alexander Capehart (OxygenCobalt) */ @Dao -interface PlaylistDao { +abstract class PlaylistDao { /** * Read out all playlists stored in the database. * @@ -59,7 +59,7 @@ interface PlaylistDao { */ @Transaction @Query("SELECT * FROM PlaylistInfo") - suspend fun readRawPlaylists(): List + abstract suspend fun readRawPlaylists(): List /** * Create a new playlist. @@ -67,7 +67,7 @@ interface PlaylistDao { * @param rawPlaylist The [RawPlaylist] to create. */ @Transaction - suspend fun insertPlaylist(rawPlaylist: RawPlaylist) { + open suspend fun insertPlaylist(rawPlaylist: RawPlaylist) { insertInfo(rawPlaylist.playlistInfo) insertSongs(rawPlaylist.songs) insertRefs( @@ -83,7 +83,7 @@ interface PlaylistDao { * @param playlistInfo The new [PlaylistInfo] to store. */ @Transaction - suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) { + open suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) { deleteInfo(playlistInfo.playlistUid) insertInfo(playlistInfo) } @@ -94,7 +94,7 @@ interface PlaylistDao { * @param playlistUid The [Music.UID] of the playlist to delete. */ @Transaction - suspend fun deletePlaylist(playlistUid: Music.UID) { + open suspend fun deletePlaylist(playlistUid: Music.UID) { deleteInfo(playlistUid) deleteRefs(playlistUid) } @@ -106,7 +106,7 @@ interface PlaylistDao { * @param songs The [PlaylistSong] representing each song to put into the playlist. */ @Transaction - suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List) { + open suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List) { insertSongs(songs) insertRefs( songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) @@ -120,7 +120,7 @@ interface PlaylistDao { * playlist. */ @Transaction - suspend fun replacePlaylistSongs(playlistUid: Music.UID, songs: List) { + open suspend fun replacePlaylistSongs(playlistUid: Music.UID, songs: List) { deleteRefs(playlistUid) insertSongs(songs) insertRefs( @@ -128,21 +128,22 @@ interface PlaylistDao { } /** Internal, do not use. */ - @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertInfo(info: PlaylistInfo) + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun insertInfo(info: PlaylistInfo) /** Internal, do not use. */ @Query("DELETE FROM PlaylistInfo where playlistUid = :playlistUid") - suspend fun deleteInfo(playlistUid: Music.UID) + abstract suspend fun deleteInfo(playlistUid: Music.UID) /** Internal, do not use. */ @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSongs(songs: List) + abstract suspend fun insertSongs(songs: List) /** Internal, do not use. */ @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insertRefs(refs: List) + abstract suspend fun insertRefs(refs: List) /** Internal, do not use. */ @Query("DELETE FROM PlaylistSongCrossRef where playlistUid = :playlistUid") - suspend fun deleteRefs(playlistUid: Music.UID) + abstract suspend fun deleteRefs(playlistUid: Music.UID) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaySong.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaySong.kt new file mode 100644 index 000000000..34dd90929 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaySong.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaySong.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.playback + +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Playlist + +/** + * Configuration to play a song in a desired way. + * + * Since songs are not [MusicParent]s, the way the queue is generated around them has a lot more + * flexibility. The particular strategy used can be configured the user, but it also needs to be + * transferred between views at points (such as menus). [PlaySong] provides both of these, being a + * enum-like datatype when configuration is needed, and an algebraic datatype when data transfer is + * needed. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface PlaySong { + /** + * The integer representation of this instance. + * + * @see fromIntCode + */ + val intCode: Int + + /** Play a Song from the entire library of songs. */ + data object FromAll : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_FROM_ALL + } + + /** Play a song from it's album. */ + data object FromAlbum : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_FROM_ALBUM + } + + /** + * Play a song from (possibly) one of it's [Artist]s. + * + * @param which The [Artist] to specifically play from. If null, the user will be prompted for + * an [Artist] to choose of the song has multiple. Otherwise, the only [Artist] will be used. + */ + data class FromArtist(val which: Artist?) : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_FROM_ARTIST + } + + /** + * Play a song from (possibly) one of it's [Genre]s. + * + * @param which The [Genre] to specifically play from. If null, the user will be prompted for a + * [Genre] to choose of the song has multiple. Otherwise, the only [Genre] will be used. + */ + data class FromGenre(val which: Genre?) : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_FROM_GENRE + } + + /** + * Play a song from one of it's [Playlist]s. + * + * @param which The [Playlist] to specifically play from. This must be provided. + */ + data class FromPlaylist(val which: Playlist) : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_FROM_PLAYLIST + } + + /** Only play the given song, include nothing else in the queue. */ + data object ByItself : PlaySong { + override val intCode = IntegerTable.PLAY_SONG_BY_ITSELF + } + + companion object { + /** + * Convert a [PlaySong] integer representation into an instance. + * + * @param intCode An integer representation of a [PlaySong] + * @param which Optional [MusicParent] to automatically populate a [FromArtist], + * [FromGenre], or [FromPlaylist] instance. If the type of the [MusicParent] does not + * match, it will be considered invalid and null will be returned. + * @return The corresponding [PlaySong], or null if the [PlaySong] is invalid. + * @see PlaySong.intCode + */ + fun fromIntCode(intCode: Int, which: MusicParent? = null): PlaySong? = + when (intCode) { + IntegerTable.PLAY_SONG_BY_ITSELF -> ByItself + IntegerTable.PLAY_SONG_FROM_ALL -> FromAll + IntegerTable.PLAY_SONG_FROM_ALBUM -> FromAlbum + IntegerTable.PLAY_SONG_FROM_ARTIST -> + if (which is Artist?) { + FromArtist(which) + } else { + null + } + IntegerTable.PLAY_SONG_FROM_GENRE -> + if (which is Genre?) { + FromGenre(which) + } else { + null + } + IntegerTable.PLAY_SONG_FROM_PLAYLIST -> + if (which is Playlist) { + FromPlaylist(which) + } else { + null + } + else -> null + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 036f07c99..e21aebb63 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -70,7 +70,6 @@ class PlaybackBarFragment : ViewBindingFragment() { // Set up actions binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() } - setupSecondaryActions(binding, playbackModel.currentBarAction) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources. @@ -81,6 +80,11 @@ class PlaybackBarFragment : ViewBindingFragment() { collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.positionDs, ::updatePosition) + collectImmediately( + playbackModel.currentBarAction, + playbackModel.repeatMode, + playbackModel.isShuffled, + ::updateBarAction) } override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) { @@ -90,39 +94,6 @@ class PlaybackBarFragment : ViewBindingFragment() { binding.playbackInfo.isSelected = false } - private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) { - when (actionMode) { - ActionMode.NEXT -> { - logD("Setting up skip next action") - binding.playbackSecondaryAction.apply { - setIconResource(R.drawable.ic_skip_next_24) - contentDescription = getString(R.string.desc_skip_next) - iconTint = context.getAttrColorCompat(MR.attr.colorOnSurfaceVariant) - setOnClickListener { playbackModel.next() } - } - } - ActionMode.REPEAT -> { - logD("Setting up repeat mode action") - binding.playbackSecondaryAction.apply { - contentDescription = getString(R.string.desc_change_repeat) - iconTint = context.getColorCompat(R.color.sel_activatable_icon) - setOnClickListener { playbackModel.toggleRepeatMode() } - collectImmediately(playbackModel.repeatMode, ::updateRepeat) - } - } - ActionMode.SHUFFLE -> { - logD("Setting up shuffle action") - binding.playbackSecondaryAction.apply { - setIconResource(R.drawable.sel_shuffle_state_24) - contentDescription = getString(R.string.desc_shuffle) - iconTint = context.getColorCompat(R.color.sel_activatable_icon) - setOnClickListener { playbackModel.toggleShuffled() } - collectImmediately(playbackModel.isShuffled, ::updateShuffled) - } - } - } - } - private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. @@ -141,19 +112,55 @@ class PlaybackBarFragment : ViewBindingFragment() { requireBinding().playbackPlayPause.isActivated = isPlaying } - private fun updateRepeat(repeatMode: RepeatMode) { - requireBinding().playbackSecondaryAction.apply { - setIconResource(repeatMode.icon) - // Icon tinting is controlled through isActivated, so update that flag as well. - isActivated = repeatMode != RepeatMode.NONE - } - } - - private fun updateShuffled(isShuffled: Boolean) { - requireBinding().playbackSecondaryAction.isActivated = isShuffled - } - private fun updatePosition(positionDs: Long) { requireBinding().playbackProgressBar.progress = positionDs.toInt() } + + private fun updateBarAction( + actionMode: ActionMode, + repeatMode: RepeatMode, + isShuffled: Boolean + ) { + val binding = requireBinding() + when (actionMode) { + ActionMode.NEXT -> { + logD("Using skip next action") + binding.playbackSecondaryAction.apply { + if (tag != actionMode) { + setIconResource(R.drawable.ic_skip_next_24) + contentDescription = getString(R.string.desc_skip_next) + iconTint = context.getAttrColorCompat(MR.attr.colorOnSurfaceVariant) + setOnClickListener { playbackModel.next() } + tag = actionMode + } + } + } + ActionMode.REPEAT -> { + logD("Using repeat mode action") + binding.playbackSecondaryAction.apply { + if (tag != actionMode) { + contentDescription = getString(R.string.desc_change_repeat) + iconTint = context.getColorCompat(R.color.sel_activatable_icon) + setOnClickListener { playbackModel.toggleRepeatMode() } + tag = actionMode + } + setIconResource(repeatMode.icon) + isActivated = repeatMode != RepeatMode.NONE + } + } + ActionMode.SHUFFLE -> { + logD("Using shuffle action") + binding.playbackSecondaryAction.apply { + if (tag != actionMode) { + setIconResource(R.drawable.sel_shuffle_state_24) + contentDescription = getString(R.string.desc_shuffle) + iconTint = context.getColorCompat(R.color.sel_activatable_icon) + setOnClickListener { playbackModel.toggleShuffled() } + tag = actionMode + } + isActivated = isShuffled + } + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt index 36fb9a0ee..37e3eb4c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt @@ -32,5 +32,6 @@ interface PlaybackModule { @Singleton @Binds fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager + @Binds fun settings(playbackSettings: PlaybackSettingsImpl): PlaybackSettings } 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 b2026b7d1..a893f455f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,21 +29,29 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint +import java.lang.reflect.Field +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.list.ListViewModel +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.queue.QueueViewModel import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.ui.PlaybackPagerAdapter import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.playback.ui.SwipeCoverView import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.systemBarInsetsCompat @@ -62,9 +70,11 @@ class PlaybackPanelFragment : StyledSeekBar.Listener, SwipeCoverView.OnSwipeListener { private val playbackModel: PlaybackViewModel by activityViewModels() - private val musicModel: MusicViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() + private val queueModel: QueueViewModel by activityViewModels() + private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null + private var coverAdapter: PlaybackPagerAdapter? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackPanelBinding.inflate(inflater) @@ -92,26 +102,22 @@ class PlaybackPanelFragment : binding.playbackToolbar.apply { setNavigationOnClickListener { playbackModel.openMain() } setOnMenuItemClickListener(this@PlaybackPanelFragment) - } - - // Set up marquee on song information, alongside click handlers that navigate to each - // respective item. - binding.playbackSong.apply { - isSelected = true - setOnClickListener { + overrideOnOverflowMenuClick { playbackModel.song.value?.let { - detailModel.showAlbum(it) - playbackModel.openMain() + // No playback options are actually available in the menu, so use a junk + // PlaySong option. + listModel.openMenu(R.menu.playback_song, it, PlaySong.ByItself) } } } - binding.playbackArtist.apply { - isSelected = true - setOnClickListener { navigateToCurrentArtist() } - } - binding.playbackAlbum.apply { - isSelected = true - setOnClickListener { navigateToCurrentAlbum() } + + // cover carousel adapter + coverAdapter = PlaybackPagerAdapter(this) + binding.playbackCoverPager.apply { + adapter = coverAdapter + registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) + val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView + recycler.isNestedScrollingEnabled = false } binding.playbackCover.onSwipeListener = this binding.playbackSeekBar.listener = this @@ -131,64 +137,40 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) + collectImmediately(queueModel.queue, ::updateQueue) + collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { equalizerLauncher = null + coverAdapter = null binding.playbackToolbar.setOnMenuItemClickListener(null) - // Marquee elements leak if they are not disabled when the views are destroyed. - binding.playbackSong.isSelected = false - binding.playbackArtist.isSelected = false - binding.playbackAlbum.isSelected = false } - override fun onMenuItemClick(item: MenuItem) = - when (item.itemId) { - R.id.action_open_equalizer -> { - // Launch the system equalizer app, if possible. - logD("Launching equalizer") - val equalizerIntent = - Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) - // Provide audio session ID so the equalizer can show options for this app - // in particular. - .putExtra( - AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) - // Signal music type so that the equalizer settings are appropriate for - // music playback. - .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - try { - requireNotNull(equalizerLauncher) { - "Equalizer panel launcher was not available" - } - .launch(equalizerIntent) - } catch (e: ActivityNotFoundException) { - requireContext().showToast(R.string.err_no_app) - } - true - } - R.id.action_go_artist -> { - navigateToCurrentArtist() - true - } - R.id.action_go_album -> { - navigateToCurrentAlbum() - true + override fun onMenuItemClick(item: MenuItem): Boolean { + if (item.itemId == R.id.action_open_equalizer) { + // Launch the system equalizer app, if possible. + logD("Launching equalizer") + val equalizerIntent = + Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) + // Provide audio session ID so the equalizer can show options for this app + // in particular. + .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) + // Signal music type so that the equalizer settings are appropriate for + // music playback. + .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + try { + requireNotNull(equalizerLauncher) { "Equalizer panel launcher was not available" } + .launch(equalizerIntent) + } catch (e: ActivityNotFoundException) { + requireContext().showToast(R.string.err_no_app) } - R.id.action_playlist_add -> { - playbackModel.song.value?.let(musicModel::addToPlaylist) - true - } - R.id.action_song_detail -> { - playbackModel.song.value?.let(detailModel::showSong) - true - } - R.id.action_share -> { - playbackModel.song.value?.let { requireContext().share(it) } - true - } - else -> false + return true } + return false + } + override fun onSeekConfirmed(positionDs: Long) { playbackModel.seekTo(positionDs) } @@ -208,12 +190,7 @@ class PlaybackPanelFragment : } val binding = requireBinding() - val context = requireContext() logD("Updating song display: $song") - binding.playbackCover.bind(song) - binding.playbackSong.text = song.name.resolve(context) - binding.playbackArtist.text = song.artists.resolveNames(context) - binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -243,17 +220,43 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun navigateToCurrentArtist() { - playbackModel.song.value?.let { - detailModel.showArtist(it) - playbackModel.openMain() - } + override fun navigateToCurrentSong() { + playbackModel.song.value?.let(detailModel::showAlbum) } - private fun navigateToCurrentAlbum() { - playbackModel.song.value?.let { - detailModel.showAlbum(it.album) - playbackModel.openMain() + override fun navigateToCurrentArtist() { + playbackModel.song.value?.let(detailModel::showArtist) + } + + override fun navigateToCurrentAlbum() { + playbackModel.song.value?.let { detailModel.showAlbum(it.album) } + } + + override fun navigateToMenu() { + // TODO + } + + private class OnCoverChangedCallback(private val queueViewModel: QueueViewModel) : + OnPageChangeCallback() { + + private var targetPosition = RecyclerView.NO_POSITION + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + targetPosition = position + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE && + targetPosition != RecyclerView.NO_POSITION && + targetPosition != queueViewModel.index.value) { + queueViewModel.goto(targetPosition) + } } } + + private companion object { + val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 8ec5db941..a270c5c07 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -24,7 +24,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp import org.oxycblt.auxio.settings.Settings @@ -46,16 +45,13 @@ interface PlaybackSettings : Settings { val replayGainMode: ReplayGainMode /** The current ReplayGain pre-amp configuration. */ var replayGainPreAmp: ReplayGainPreAmp + /** How to play a song from a general list of songs, specified by [PlaySong] */ + val playInListWith: PlaySong /** - * What type of MusicParent to play from when a Song is played from a list of other items. Null - * if to play from all Songs. + * How to play a song from a parent item, specified by [PlaySong]. Null if to delegate to the UI + * context. */ - val inListPlaybackMode: MusicMode - /** - * What type of MusicParent to play from when a Song is played from within an item (ex. like in - * the detail view). Null if to play from the item it was played in. - */ - val inParentPlaybackMode: MusicMode? + val inParentPlaybackMode: PlaySong? /** Whether to keep shuffle on when playing a new Song. */ val keepShuffle: Boolean /** Whether to rewind when the skip previous button is pressed before skipping back. */ @@ -68,23 +64,25 @@ interface PlaybackSettings : Settings { fun onReplayGainSettingsChanged() {} /** Called when [notificationAction] has changed. */ fun onNotificationActionChanged() {} + /** Called when [barAction] has changed. */ + fun onBarActionChanged() {} } } class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Context) : Settings.Impl(context), PlaybackSettings { - override val inListPlaybackMode: MusicMode + override val playInListWith: PlaySong get() = - MusicMode.fromIntCode( + PlaySong.fromIntCode( sharedPreferences.getInt( - getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) - ?: MusicMode.SONGS + getString(R.string.set_key_play_in_list_with), Int.MIN_VALUE)) + ?: PlaySong.FromAll - override val inParentPlaybackMode: MusicMode? + override val inParentPlaybackMode: PlaySong? get() = - MusicMode.fromIntCode( + PlaySong.fromIntCode( sharedPreferences.getInt( - getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE)) + getString(R.string.set_key_play_in_parent_with), Int.MIN_VALUE)) override val barAction: ActionMode get() = @@ -130,65 +128,44 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) override fun migrate() { - // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. - if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) { - logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION") - - val mode = - if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) { - ActionMode.SHUFFLE - } else { - ActionMode.REPEAT - } - - sharedPreferences.edit { - putInt(getString(R.string.set_key_notif_action), mode.intCode) - remove(OLD_KEY_ALT_NOTIF_ACTION) - apply() - } - } - - // PlaybackMode was converted to MusicMode in 3.0.0 - - fun Int.migratePlaybackMode() = + // MusicMode was converted to PlaySong in 3.2.0 + fun Int.migrateMusicMode() = when (this) { - // Convert PlaybackMode into MusicMode - IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS - IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS - IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS - IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES + IntegerTable.MUSIC_MODE_SONGS -> PlaySong.FromAll + IntegerTable.MUSIC_MODE_ALBUMS -> PlaySong.FromAlbum + IntegerTable.MUSIC_MODE_ARTISTS -> PlaySong.FromArtist(null) + IntegerTable.MUSIC_MODE_GENRES -> PlaySong.FromGenre(null) else -> null } - if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) { - logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE") + if (sharedPreferences.contains(OLD_KEY_LIB_MUSIC_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_LIB_MUSIC_PLAYBACK_MODE") val mode = sharedPreferences - .getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) - .migratePlaybackMode() - ?: MusicMode.SONGS + .getInt(OLD_KEY_LIB_MUSIC_PLAYBACK_MODE, Int.MIN_VALUE) + .migrateMusicMode() sharedPreferences.edit { - putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode) - remove(OLD_KEY_LIB_PLAYBACK_MODE) + putInt( + getString(R.string.set_key_play_in_list_with), mode?.intCode ?: Int.MIN_VALUE) + remove(OLD_KEY_LIB_MUSIC_PLAYBACK_MODE) apply() } } - if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) { - logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE") + if (sharedPreferences.contains(OLD_KEY_DETAIL_MUSIC_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_DETAIL_MUSIC_PLAYBACK_MODE") val mode = sharedPreferences - .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE) - .migratePlaybackMode() + .getInt(OLD_KEY_DETAIL_MUSIC_PLAYBACK_MODE, Int.MIN_VALUE) + .migrateMusicMode() sharedPreferences.edit { putInt( - getString(R.string.set_key_in_parent_playback_mode), - mode?.intCode ?: Int.MIN_VALUE) - remove(OLD_KEY_DETAIL_PLAYBACK_MODE) + getString(R.string.set_key_play_in_parent_with), mode?.intCode ?: Int.MIN_VALUE) + remove(OLD_KEY_DETAIL_MUSIC_PLAYBACK_MODE) apply() } } @@ -206,12 +183,15 @@ class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Cont logD("Dispatching notification setting change") listener.onNotificationActionChanged() } + getString(R.string.set_key_bar_action) -> { + logD("Dispatching bar action change") + listener.onBarActionChanged() + } } } private companion object { - const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" - const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" - const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" + const val OLD_KEY_LIB_MUSIC_PLAYBACK_MODE = "auxio_library_playback_mode" + const val OLD_KEY_DETAIL_MUSIC_PLAYBACK_MODE = "auxio_detail_playback_mode" } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index ea6b7b53a..e497c96ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -27,13 +27,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.persist.PersistenceRepository @@ -59,15 +58,16 @@ constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val persistenceRepository: PersistenceRepository, + private val listSettings: ListSettings, private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings -) : ViewModel(), PlaybackStateManager.Listener { +) : ViewModel(), PlaybackStateManager.Listener, PlaybackSettings.Listener { private var lastPositionJob: Job? = null private val _song = MutableStateFlow(null) /** The currently playing song. */ val song: StateFlow get() = _song + private val _parent = MutableStateFlow(null) /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ val parent: StateFlow = _parent @@ -75,6 +75,7 @@ constructor( /** Whether playback is ongoing or paused. */ val isPlaying: StateFlow get() = _isPlaying + private val _positionDs = MutableStateFlow(0L) /** The current position, in deci-seconds (1/10th of a second). */ val positionDs: StateFlow @@ -84,36 +85,32 @@ constructor( /** The current [RepeatMode]. */ val repeatMode: StateFlow get() = _repeatMode + private val _isShuffled = MutableStateFlow(false) /** Whether the queue is shuffled or not. */ val isShuffled: StateFlow get() = _isShuffled - private val _openPanel = MutableEvent() - val openPanel: Event - get() = _openPanel + private val _currentBarAction = MutableStateFlow(playbackSettings.barAction) + /** The current secondary action to show alongside the play button in the playback bar. */ + val currentBarAction: StateFlow + get() = _currentBarAction - private val _artistPlaybackPickerSong = MutableEvent() + private val _openPanel = MutableEvent() /** - * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a - * [Song] from one of it's [Artist]s. - * - * @see playFromArtist + * A [OpenPanel] command that is awaiting a view capable of responding to it. Null if none + * currently. */ - val artistPickerSong: Event - get() = _artistPlaybackPickerSong + val openPanel: Event + get() = _openPanel - private val _genrePlaybackPickerSong = MutableEvent() + private val _playbackDecision = MutableEvent() /** - * Flag signaling to open a picker dialog in order to resolve an ambiguous choice when playing a - * [Song] from one of it's [Genre]s. + * A [PlaybackDecision] command that is awaiting a view capable of responding to it. Null if + * none currently. */ - val genrePickerSong: Event - get() = _genrePlaybackPickerSong - - /** The current action to show on the playback bar. */ - val currentBarAction: ActionMode - get() = playbackSettings.barAction + val playbackDecision: Event + get() = _playbackDecision /** * The current audio session ID of the internal player. Null if no [InternalPlayer] is @@ -124,10 +121,12 @@ constructor( init { playbackManager.addListener(this) + playbackSettings.registerListener(this) } override fun onCleared() { playbackManager.removeListener(this) + playbackSettings.unregisterListener(this) } override fun onIndexMoved(queue: Queue) { @@ -177,85 +176,117 @@ constructor( _repeatMode.value = repeatMode } + override fun onBarActionChanged() { + _currentBarAction.value = playbackSettings.barAction + } + // --- PLAYING FUNCTIONS --- + fun play(song: Song, with: PlaySong) { + logD("Playing $song with $with") + playWithImpl(song, with, isImplicitlyShuffled()) + } + + fun playExplicit(song: Song, with: PlaySong) { + playWithImpl(song, with, false) + } + + fun shuffleExplicit(song: Song, with: PlaySong) { + playWithImpl(song, with, true) + } + /** Shuffle all songs in the music library. */ fun shuffleAll() { logD("Shuffling all songs") - playImpl(null, null, true) + playFromAllImpl(null, true) } /** - * Play a [Song] from the [MusicParent] outlined by the given [MusicMode]. - * - If [MusicMode.SONGS], the [Song] is played from all songs. - * - If [MusicMode.ALBUMS], the [Song] is played from it's [Album]. - * - If [MusicMode.ARTISTS], the [Song] is played from one of it's [Artist]s. - * - If [MusicMode.GENRES], the [Song] is played from one of it's [Genre]s. - * [MusicMode.PLAYLISTS] is disallowed here. + * Play a [Song] from one of it's [Artist]s. * * @param song The [Song] to play. - * @param playbackMode The [MusicMode] to play from. - */ - fun playFrom(song: Song, playbackMode: MusicMode) { - logD("Playing $song from $playbackMode") - when (playbackMode) { - MusicMode.SONGS -> playImpl(song, null) - MusicMode.ALBUMS -> playImpl(song, song.album) - MusicMode.ARTISTS -> playFromArtist(song) - MusicMode.GENRES -> playFromGenre(song) - MusicMode.PLAYLISTS -> error("Playing from a playlist is not supported.") - } + * @param artist The [Artist] to play from. Must be linked to the [Song]. If null, the user will + * be prompted on what artist to play. Defaults to null. + */ + fun playFromArtist(song: Song, artist: Artist? = null) { + playFromArtistImpl(song, artist, isImplicitlyShuffled()) } /** - * Play a [Song] from one of it's [Artist]s. + * Play a [Song] from one of it's [Genre]s. * * @param song The [Song] to play. - * @param artist The [Artist] to play from. Must be linked to the [Song]. If null, the user will + * @param genre The [Genre] to play from. Must be linked to the [Song]. If null, the user will * be prompted on what artist to play. Defaults to null. */ - fun playFromArtist(song: Song, artist: Artist? = null) { + fun playFromGenre(song: Song, genre: Genre? = null) { + playFromGenreImpl(song, genre, isImplicitlyShuffled()) + } + + private fun isImplicitlyShuffled() = + playbackManager.queue.isShuffled && playbackSettings.keepShuffle + + private fun playWithImpl(song: Song, with: PlaySong, shuffled: Boolean) { + when (with) { + is PlaySong.FromAll -> playFromAllImpl(song, shuffled) + is PlaySong.FromAlbum -> playFromAlbumImpl(song, shuffled) + is PlaySong.FromArtist -> playFromArtistImpl(song, with.which, shuffled) + is PlaySong.FromGenre -> playFromGenreImpl(song, with.which, shuffled) + is PlaySong.FromPlaylist -> playFromPlaylistImpl(song, with.which, shuffled) + is PlaySong.ByItself -> playItselfImpl(song, shuffled) + } + } + + private fun playFromAllImpl(song: Song?, shuffled: Boolean) { + playImpl(song, null, shuffled) + } + + private fun playFromAlbumImpl(song: Song, shuffled: Boolean) { + playImpl(song, song.album, shuffled) + } + + private fun playFromArtistImpl(song: Song, artist: Artist?, shuffled: Boolean) { if (artist != null) { logD("Playing $song from $artist") - playImpl(song, artist) + playImpl(song, artist, shuffled) } else if (song.artists.size == 1) { logD("$song has one artist, playing from it") - playImpl(song, song.artists[0]) + playImpl(song, song.artists[0], shuffled) } else { logD("$song has multiple artists, showing choice dialog") - _artistPlaybackPickerSong.put(song) + startPlaybackDecision(PlaybackDecision.PlayFromArtist(song)) } } - /** - * Play a [Song] from one of it's [Genre]s. - * - * @param song The [Song] to play. - * @param genre The [Genre] to play from. Must be linked to the [Song]. If null, the user will - * be prompted on what artist to play. Defaults to null. - */ - fun playFromGenre(song: Song, genre: Genre? = null) { + private fun playFromGenreImpl(song: Song, genre: Genre?, shuffled: Boolean) { if (genre != null) { logD("Playing $song from $genre") - playImpl(song, genre) + playImpl(song, genre, shuffled) } else if (song.genres.size == 1) { logD("$song has one genre, playing from it") - playImpl(song, song.genres[0]) + playImpl(song, song.genres[0], shuffled) } else { logD("$song has multiple genres, showing choice dialog") - _genrePlaybackPickerSong.put(song) + startPlaybackDecision(PlaybackDecision.PlayFromGenre(song)) } } - /** - * PLay a [Song] from one of it's [Playlist]s. - * - * @param song The [Song] to play. - * @param playlist The [Playlist] to play from. Must be linked to the [Song]. - */ - fun playFromPlaylist(song: Song, playlist: Playlist) { + private fun playFromPlaylistImpl(song: Song, playlist: Playlist, shuffled: Boolean) { logD("Playing $song from $playlist") - playImpl(song, playlist) + playImpl(song, playlist, shuffled) + } + + private fun playItselfImpl(song: Song, shuffled: Boolean) { + playImpl(song, listOf(song), shuffled) + } + + private fun startPlaybackDecision(decision: PlaybackDecision) { + val existing = _playbackDecision.flow.value + if (existing != null) { + logD("Already handling decision $existing, ignoring $decision") + return + } + _playbackDecision.put(decision) } /** @@ -268,46 +299,6 @@ constructor( playImpl(null, album, false) } - /** - * Play an [Artist]. - * - * @param artist The [Artist] to play. - */ - fun play(artist: Artist) { - logD("Playing $artist") - playImpl(null, artist, false) - } - - /** - * Play a [Genre]. - * - * @param genre The [Genre] to play. - */ - fun play(genre: Genre) { - logD("Playing $genre") - playImpl(null, genre, false) - } - - /** - * Play a [Playlist]. - * - * @param playlist The [Playlist] to play. - */ - fun play(playlist: Playlist) { - logD("Playing $playlist") - playImpl(null, playlist, false) - } - - /** - * Play a list of [Song]s. - * - * @param songs The [Song]s to play. - */ - fun play(songs: List) { - logD("Playing ${songs.size} songs") - playbackManager.play(null, null, songs, false) - } - /** * Shuffle an [Album]. * @@ -318,6 +309,16 @@ constructor( playImpl(null, album, true) } + /** + * Play an [Artist]. + * + * @param artist The [Artist] to play. + */ + fun play(artist: Artist) { + logD("Playing $artist") + playImpl(null, artist, false) + } + /** * Shuffle an [Artist]. * @@ -328,6 +329,16 @@ constructor( playImpl(null, artist, true) } + /** + * Play a [Genre]. + * + * @param genre The [Genre] to play. + */ + fun play(genre: Genre) { + logD("Playing $genre") + playImpl(null, genre, false) + } + /** * Shuffle a [Genre]. * @@ -338,6 +349,16 @@ constructor( playImpl(null, genre, true) } + /** + * Play a [Playlist]. + * + * @param playlist The [Playlist] to play. + */ + fun play(playlist: Playlist) { + logD("Playing $playlist") + playImpl(null, playlist, false) + } + /** * Shuffle a [Playlist]. * @@ -348,6 +369,16 @@ constructor( playImpl(null, playlist, true) } + /** + * Play a list of [Song]s. + * + * @param songs The [Song]s to play. + */ + fun play(songs: List) { + logD("Playing ${songs.size} songs") + playbackManager.play(null, null, songs, false) + } + /** * Shuffle a list of [Song]s. * @@ -358,22 +389,23 @@ constructor( playbackManager.play(null, null, songs, true) } - private fun playImpl( - song: Song?, - parent: MusicParent?, - shuffled: Boolean = playbackManager.queue.isShuffled && playbackSettings.keepShuffle - ) { + private fun playImpl(song: Song?, queue: List, shuffled: Boolean) { + check(song == null || queue.contains(song)) { "Song to play not in queue" } + playbackManager.play(song, null, queue, shuffled) + } + + private fun playImpl(song: Song?, parent: MusicParent?, shuffled: Boolean) { check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } val deviceLibrary = musicRepository.deviceLibrary ?: return val queue = when (parent) { - is Genre -> musicSettings.genreSongSort.songs(parent.songs) - is Artist -> musicSettings.artistSongSort.songs(parent.songs) - is Album -> musicSettings.albumSongSort.songs(parent.songs) + is Genre -> listSettings.genreSongSort.songs(parent.songs) + is Artist -> listSettings.artistSongSort.songs(parent.songs) + is Album -> listSettings.albumSongSort.songs(parent.songs) is Playlist -> parent.songs - null -> musicSettings.songSort.songs(deviceLibrary.songs) + null -> listSettings.songSort.songs(deviceLibrary.songs) } playbackManager.play(song, parent, queue, shuffled) } @@ -432,7 +464,7 @@ constructor( */ fun playNext(album: Album) { logD("Playing $album next") - playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs)) + playbackManager.playNext(listSettings.albumSongSort.songs(album.songs)) } /** @@ -442,7 +474,7 @@ constructor( */ fun playNext(artist: Artist) { logD("Playing $artist next") - playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs)) + playbackManager.playNext(listSettings.artistSongSort.songs(artist.songs)) } /** @@ -452,7 +484,7 @@ constructor( */ fun playNext(genre: Genre) { logD("Playing $genre next") - playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs)) + playbackManager.playNext(listSettings.genreSongSort.songs(genre.songs)) } /** @@ -492,7 +524,7 @@ constructor( */ fun addToQueue(album: Album) { logD("Adding $album to queue") - playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs)) + playbackManager.addToQueue(listSettings.albumSongSort.songs(album.songs)) } /** @@ -502,7 +534,7 @@ constructor( */ fun addToQueue(artist: Artist) { logD("Adding $artist to queue") - playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs)) + playbackManager.addToQueue(listSettings.artistSongSort.songs(artist.songs)) } /** @@ -512,7 +544,7 @@ constructor( */ fun addToQueue(genre: Genre) { logD("Adding $genre to queue") - playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs)) + playbackManager.addToQueue(listSettings.genreSongSort.songs(genre.songs)) } /** @@ -560,11 +592,20 @@ constructor( } // --- UI CONTROL --- - fun openMain() = openImpl(Panel.Main) - fun openPlayback() = openImpl(Panel.Playback) - fun openQueue() = openImpl(Panel.Queue) - private fun openImpl(panel: Panel) { + /** Open the main panel, closing all other panels. */ + fun openMain() = openImpl(OpenPanel.MAIN) + + /** Open the playback panel, closing the queue panel if needed. */ + fun openPlayback() = openImpl(OpenPanel.PLAYBACK) + + /** + * Open the queue panel, assuming that it exists in the current layout, is collapsed, and with + * the playback panel already being expanded. + */ + fun openQueue() = openImpl(OpenPanel.QUEUE) + + private fun openImpl(panel: OpenPanel) { val existing = openPanel.flow.value if (existing != null) { logD("Already opening $existing, ignoring opening $panel") @@ -617,8 +658,33 @@ constructor( } } -sealed interface Panel { - object Main : Panel - object Playback : Panel - object Queue : Panel +/** + * Command for controlling the main playback panel UI. + * + * @author Alexander Capehart (OxygenCobalt) + */ +enum class OpenPanel { + /** Open the main view, collapsing all other panels. */ + MAIN, + /** Open the playback panel, collapsing the queue panel if applicable. */ + PLAYBACK, + /** + * Open the queue panel, assuming that it exists in the current layout, is collapsed, and with + * the playback panel already being expanded. Do nothing if these conditions are not met. + */ + QUEUE +} + +/** + * Command for opening decision dialogs when playback from a [Song] is ambiguous. + * + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface PlaybackDecision { + /** The [Song] currently attempting to be played from. */ + val song: Song + /** Navigate to a dialog to determine which [Artist] a [Song] should be played from. */ + class PlayFromArtist(override val song: Song) : PlaybackDecision + /** Navigate to a dialog to determine which [Genre] a [Song] should be played from. */ + class PlayFromGenre(override val song: Song) : PlaybackDecision } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/ArtistPlaybackChoiceAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/playback/decision/ArtistPlaybackChoiceAdapter.kt index be8bed183..025c71b89 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/ArtistPlaybackChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/ArtistPlaybackChoiceAdapter.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.decision import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/GenrePlaybackChoiceAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/playback/decision/GenrePlaybackChoiceAdapter.kt index f5fdbf970..584f31e00 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/GenrePlaybackChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/GenrePlaybackChoiceAdapter.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.decision import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt index e42e6ff61..8e318ab3c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromArtistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromArtistDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.decision import android.os.Bundle import android.view.LayoutInflater @@ -32,20 +32,21 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Artist] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Artist] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromArtistDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments @@ -68,15 +69,9 @@ class PlayFromArtistDialog : adapter = choiceAdapter } + playbackModel.playbackDecision.consume() pickerModel.setPickerSongUid(args.artistUid) - collectImmediately(pickerModel.currentPickerSong) { - if (it != null) { - choiceAdapter.update(it.artists, UpdateInstructions.Replace(0)) - } else { - logD("No song to show choices for, navigating away") - findNavController().navigateUp() - } - } + collectImmediately(pickerModel.currentPickerSong, ::updateSong) } override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { @@ -90,4 +85,13 @@ class PlayFromArtistDialog : playbackModel.playFromArtist(song, item) findNavController().navigateUp() } + + private fun updateSong(song: Song?) { + if (song == null) { + logD("No song to show choices for, navigating away") + findNavController().navigateUp() + return + } + choiceAdapter.update(song.artists, UpdateInstructions.Replace(0)) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt rename to app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt index 6811e3510..831e5c6e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlayFromGenreDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlayFromGenreDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.decision import android.os.Bundle import android.view.LayoutInflater @@ -32,20 +32,21 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. + * A picker [ViewBindingMaterialDialogFragment] intended for when [Genre] playback is ambiguous. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class PlayFromGenreDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val pickerModel: PlaybackPickerViewModel by viewModels() // Information about what Song to show choices for is initially within the navigation arguments @@ -68,15 +69,9 @@ class PlayFromGenreDialog : adapter = choiceAdapter } + playbackModel.playbackDecision.consume() pickerModel.setPickerSongUid(args.genreUid) - collectImmediately(pickerModel.currentPickerSong) { - if (it != null) { - choiceAdapter.update(it.genres, UpdateInstructions.Replace(0)) - } else { - logD("No song to show choices for, navigating away") - findNavController().navigateUp() - } - } + collectImmediately(pickerModel.currentPickerSong, ::updateSong) } override fun onDestroyBinding(binding: DialogMusicChoicesBinding) { @@ -90,4 +85,13 @@ class PlayFromGenreDialog : playbackModel.playFromGenre(song, item) findNavController().navigateUp() } + + private fun updateSong(song: Song?) { + if (song == null) { + logD("No song to show choices for, navigating away") + findNavController().navigateUp() + return + } + choiceAdapter.update(song.genres, UpdateInstructions.Replace(0)) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlaybackPickerViewModel.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/playback/decision/PlaybackPickerViewModel.kt index 644b5a580..d8dae6ae4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/picker/PlaybackPickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/decision/PlaybackPickerViewModel.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.picker +package org.oxycblt.auxio.playback.decision import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index c5ebf9904..bc421b846 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -114,12 +114,14 @@ class MutableQueue : Queue { @Volatile override var index = -1 private set + override val currentSong: Song? get() = shuffledMapping .ifEmpty { orderedMapping.ifEmpty { null } } ?.getOrNull(index) ?.let(heap::get) + override val isShuffled: Boolean get() = shuffledMapping.isNotEmpty() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index dcd7db42e..6cc1cea54 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -28,16 +28,17 @@ import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD /** - * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. + * aa [ViewBindingMaterialDialogFragment] that allows user configuration of the current + * [ReplayGainPreAmp]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint -class PreAmpCustomizeDialog : ViewBindingDialogFragment() { +class PreAmpCustomizeDialog : ViewBindingMaterialDialogFragment() { @Inject lateinit var playbackSettings: PlaybackSettings override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 5c039ab8c..bd4ef874c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -22,7 +22,7 @@ import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player import androidx.media3.common.audio.AudioProcessor -import androidx.media3.exoplayer.audio.BaseAudioProcessor +import androidx.media3.common.audio.BaseAudioProcessor import java.nio.ByteBuffer import javax.inject.Inject import kotlin.math.pow @@ -81,6 +81,7 @@ constructor( applyReplayGain(queue.currentSong) } } + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { logD("New playback started, updating playback information") applyReplayGain(queue.currentSong) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt index 17186e181..0980a1575 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/InternalPlayer.kt @@ -77,13 +77,13 @@ interface InternalPlayer { /** Possible long-running background tasks handled by the background playback task. */ sealed interface Action { /** Restore the previously saved playback state. */ - object RestoreState : Action + data object RestoreState : Action /** * Start shuffled playback of the entire music library. Analogous to the "Shuffle All" * shortcut. */ - object ShuffleAll : Action + data object ShuffleAll : Action /** * Start playing an audio file at the given [Uri]. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 388087653..870d7a84e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -309,15 +309,18 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { @Volatile override var parent: MusicParent? = null private set + @Volatile override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) private set + @Volatile override var repeatMode = RepeatMode.NONE set(value) { field = value notifyRepeatModeChanged() } + override val currentAudioSessionId: Int? get() = internalPlayer?.audioSessionId diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt index b875636b8..65192cf10 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.util.logD class MediaButtonReceiver : BroadcastReceiver() { @Inject lateinit var playbackManager: PlaybackStateManager + // TODO: Figure this out override fun onReceive(context: Context, intent: Intent) { if (playbackManager.queue.currentSong != null) { // We have a song, so we can assume that the service will start a foreground state. 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 9acbe82d0..1910b1a01 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 @@ -173,7 +173,7 @@ constructor( // --- SETTINGS OVERRIDES --- - override fun onCoverModeChanged() { + override fun onImageSettingsChanged() { // Need to reload the metadata cover. updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) } @@ -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/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index ffcc84b41..7ac1c66cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -25,8 +25,8 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import android.media.audiofx.AudioEffect -import android.os.Build import android.os.IBinder +import androidx.core.content.ContextCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem @@ -47,8 +47,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository @@ -99,8 +99,8 @@ class PlaybackService : @Inject lateinit var playbackManager: PlaybackStateManager @Inject lateinit var playbackSettings: PlaybackSettings @Inject lateinit var persistenceRepository: PersistenceRepository + @Inject lateinit var listSettings: ListSettings @Inject lateinit var musicRepository: MusicRepository - @Inject lateinit var musicSettings: MusicSettings // State private lateinit var foregroundManager: ForegroundManager @@ -121,14 +121,14 @@ class PlaybackService : // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), MediaCodecAudioRenderer( this, MediaCodecSelector.DEFAULT, handler, audioListener, AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, - replayGainProcessor), - FfmpegAudioRenderer(handler, audioListener, replayGainProcessor)) + replayGainProcessor)) } player = @@ -165,18 +165,8 @@ class PlaybackService : addAction(WidgetProvider.ACTION_WIDGET_UPDATE) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - registerReceiver( - systemReceiver, - intentFilter, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RECEIVER_NOT_EXPORTED - } else { - 0 - }) - } else { - registerReceiver(systemReceiver, intentFilter) - } + ContextCompat.registerReceiver( + this, systemReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED) logD("Service created") } @@ -379,7 +369,7 @@ class PlaybackService : is InternalPlayer.Action.ShuffleAll -> { logD("Shuffling all tracks") playbackManager.play( - null, null, musicSettings.songSort.songs(deviceLibrary.songs), true) + null, null, listSettings.songSort.songs(deviceLibrary.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { @@ -388,7 +378,7 @@ class PlaybackService : playbackManager.play( song, null, - musicSettings.songSort.songs(deviceLibrary.songs), + listSettings.songSort.songs(deviceLibrary.songs), playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt new file mode 100644 index 000000000..d0f176231 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/PlaybackPagerAdapter.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaybackPagerAdapter.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.playback.ui + +import android.view.ViewGroup +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlin.jvm.internal.Intrinsics +import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.inflater + +/** @author Koitharu, Alexander Capehart (OxygenCobalt) */ +class PlaybackPagerAdapter(private val listener: Listener) : + FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { + return CoverViewHolder.from(parent) + } + + override fun onBindViewHolder(holder: CoverViewHolder, position: Int) { + holder.bind(getItem(position), listener) + } + + override fun onViewRecycled(holder: CoverViewHolder) { + holder.recycle() + super.onViewRecycled(holder) + } + + interface Listener { + fun navigateToCurrentArtist() + + fun navigateToCurrentAlbum() + + fun navigateToCurrentSong() + + fun navigateToMenu() + } +} + +class CoverViewHolder private constructor(private val binding: ItemPlaybackSongBinding) : + RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver { + init { + binding.root.layoutParams = + RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.MATCH_PARENT) + } + + /** + * Bind new data to this instance. + * + * @param item The new [Song] to bind. + */ + fun bind(item: Song, listener: PlaybackPagerAdapter.Listener) { + val context = binding.root.context + binding.playbackCover.bind(item) + // binding.playbackCover.bind(item) + binding.playbackSong.apply { + text = item.name.resolve(context) + setOnClickListener { listener.navigateToCurrentSong() } + } + binding.playbackArtist.apply { + text = item.artists.resolveNames(context) + setOnClickListener { listener.navigateToCurrentArtist() } + } + binding.playbackAlbum.apply { + text = item.album.name.resolve(context) + setOnClickListener { listener.navigateToCurrentAlbum() } + } + setSelected(true) + } + + fun recycle() { + // Marquee elements leak if they are not disabled when the views are destroyed. + // TODO: Move to TextView impl to avoid having to deal with lifecycle here + setSelected(false) + } + + private fun setSelected(value: Boolean) { + binding.playbackSong.isSelected = value + binding.playbackArtist.isSelected = value + binding.playbackAlbum.isSelected = value + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup) = + CoverViewHolder(ItemPlaybackSongBinding.inflate(parent.context.inflater)) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.uid == newItem.uid + + override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } + } +} 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 4efc4c704..da74d66a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -36,12 +36,12 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.Show -import org.oxycblt.auxio.home.HomeFragmentDirections import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.selection.SelectionViewModel +import org.oxycblt.auxio.list.ListViewModel +import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -51,6 +51,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.Playlist 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.util.collect import org.oxycblt.auxio.util.collectImmediately @@ -70,11 +71,11 @@ import org.oxycblt.auxio.util.setFullWidthLookup */ @AndroidEntryPoint class SearchFragment : ListFragment() { - override val detailModel: DetailViewModel by activityViewModels() + private val searchModel: SearchViewModel by viewModels() + private val detailModel: DetailViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() - override val selectionModel: SelectionViewModel by activityViewModels() - private val searchModel: SearchViewModel by viewModels() private val searchAdapter = SearchAdapter(this) private var imm: InputMethodManager? = null private var launchedKeyboard = false @@ -118,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 } @@ -138,10 +139,12 @@ class SearchFragment : ListFragment() { // --- VIEWMODEL SETUP --- collectImmediately(searchModel.searchResults, ::updateSearchResults) - collectImmediately(selectionModel.selected, ::updateSelection) + collectImmediately(listModel.selected, ::updateSelection) + collect(listModel.menu.flow, ::handleMenu) collect(musicModel.playlistDecision.flow, ::handleDecision) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) + collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) collect(detailModel.toShow.flow, ::handleShow) } @@ -171,7 +174,7 @@ class SearchFragment : ListFragment() { override fun onRealClick(item: Music) { when (item) { - is Song -> playbackModel.playFrom(item, searchModel.playbackMode) + is Song -> playbackModel.play(item, searchModel.playWith) is Album -> detailModel.showAlbum(item) is Artist -> detailModel.showArtist(item) is Genre -> detailModel.showGenre(item) @@ -179,13 +182,13 @@ class SearchFragment : ListFragment() { } } - override fun onOpenMenu(item: Music, anchor: View) { + override fun onOpenMenu(item: Music) { when (item) { - is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) - is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) - is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Genre -> openMusicMenu(anchor, R.menu.menu_parent_actions, item) - is Playlist -> openMusicMenu(anchor, R.menu.menu_playlist_actions, 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) } } @@ -209,36 +212,29 @@ class SearchFragment : ListFragment() { logD("Navigating to ${show.song}") findNavController().navigateSafe(SearchFragmentDirections.showSong(show.song.uid)) } - - // Songs should be scrolled to if the album matches, or a new detail - // fragment should be launched otherwise. is Show.SongAlbumDetails -> { logD("Navigating to the album of ${show.song}") findNavController() .navigateSafe(SearchFragmentDirections.showAlbum(show.song.album.uid)) } - - // If the album matches, no need to do anything. Otherwise launch a new - // detail fragment. is Show.AlbumDetails -> { logD("Navigating to ${show.album}") findNavController().navigateSafe(SearchFragmentDirections.showAlbum(show.album.uid)) } - - // Always launch a new ArtistDetailFragment. is Show.ArtistDetails -> { logD("Navigating to ${show.artist}") findNavController() .navigateSafe(SearchFragmentDirections.showArtist(show.artist.uid)) } - is Show.SongArtistDetails -> { + is Show.SongArtistDecision -> { logD("Navigating to artist choices for ${show.song}") - findNavController().navigateSafe(SearchFragmentDirections.showArtist(show.song.uid)) + findNavController() + .navigateSafe(SearchFragmentDirections.showArtistChoices(show.song.uid)) } - is Show.AlbumArtistDetails -> { + is Show.AlbumArtistDecision -> { logD("Navigating to artist choices for ${show.album}") findNavController() - .navigateSafe(SearchFragmentDirections.showArtist(show.album.uid)) + .navigateSafe(SearchFragmentDirections.showArtistChoices(show.album.uid)) } is Show.GenreDetails -> { logD("Navigating to ${show.genre}") @@ -247,7 +243,7 @@ class SearchFragment : ListFragment() { is Show.PlaylistDetails -> { logD("Navigating to ${show.playlist}") findNavController() - .navigateSafe(SearchFragmentDirections.showGenre(show.playlist.uid)) + .navigateSafe(SearchFragmentDirections.showPlaylist(show.playlist.uid)) } null -> {} } @@ -256,39 +252,20 @@ class SearchFragment : ListFragment() { hideKeyboard() } - private fun handleDecision(decision: PlaylistDecision?) { - if (decision == null) return - when (decision) { - is PlaylistDecision.New -> { - logD("Creating new playlist") - findNavController() - .navigateSafe( - HomeFragmentDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray())) + private fun handleMenu(menu: Menu?) { + if (menu == null) return + val directions = + when (menu) { + is Menu.ForSong -> SearchFragmentDirections.openSongMenu(menu.parcel) + is Menu.ForAlbum -> SearchFragmentDirections.openAlbumMenu(menu.parcel) + 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) } - is PlaylistDecision.Rename -> { - logD("Renaming ${decision.playlist}") - findNavController() - .navigateSafe(HomeFragmentDirections.renamePlaylist(decision.playlist.uid)) - } - is PlaylistDecision.Delete -> { - logD("Deleting ${decision.playlist}") - findNavController() - .navigateSafe(SearchFragmentDirections.deletePlaylist(decision.playlist.uid)) - } - is PlaylistDecision.Add -> { - logD("Adding ${decision.songs.size} to a playlist") - findNavController() - .navigateSafe( - HomeFragmentDirections.addToPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - } - } - musicModel.playlistDecision.consume() - } - - private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - searchAdapter.setPlaying(parent ?: song, isPlaying) + findNavController().navigateSafe(directions) + // Keyboard is no longer needed. + hideKeyboard() } private fun updateSelection(selected: List) { @@ -306,6 +283,50 @@ class SearchFragment : ListFragment() { } } + private fun handleDecision(decision: PlaylistDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaylistDecision.Rename -> { + logD("Renaming ${decision.playlist}") + SearchFragmentDirections.renamePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Delete -> { + logD("Deleting ${decision.playlist}") + SearchFragmentDirections.deletePlaylist(decision.playlist.uid) + } + is PlaylistDecision.Add -> { + logD("Adding ${decision.songs.size} to a playlist") + SearchFragmentDirections.addToPlaylist( + decision.songs.map { it.uid }.toTypedArray()) + } + is PlaylistDecision.New -> { + error("Unexpected decision $decision") + } + } + findNavController().navigateSafe(directions) + } + + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { + searchAdapter.setPlaying(parent ?: song, isPlaying) + } + + private fun handlePlaybackDecision(decision: PlaybackDecision?) { + if (decision == null) return + val directions = + when (decision) { + is PlaybackDecision.PlayFromArtist -> { + logD("Launching play from artist dialog for $decision") + SearchFragmentDirections.playFromArtist(decision.song.uid) + } + is PlaybackDecision.PlayFromGenre -> { + logD("Launching play from artist dialog for $decision") + SearchFragmentDirections.playFromGenre(decision.song.uid) + } + } + findNavController().navigateSafe(directions) + } + /** * Safely focus the keyboard on a particular [View]. * diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt index 18c857ba4..fb8e7e063 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt @@ -27,5 +27,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface SearchModule { @Binds fun engine(searchEngine: SearchEngineImpl): SearchEngine + @Binds fun settings(searchSettings: SearchSettingsImpl): SearchSettings } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt index 16edab48b..71fd94583 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt @@ -23,7 +23,7 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.settings.Settings /** @@ -32,19 +32,21 @@ import org.oxycblt.auxio.settings.Settings * @author Alexander Capehart (OxygenCobalt) */ interface SearchSettings : Settings { - /** The type of Music the search view is currently filtering to. */ - var searchFilterMode: MusicMode? + /** The type of Music the search view is should filter to. */ + var filterTo: MusicType? } class SearchSettingsImpl @Inject constructor(@ApplicationContext context: Context) : Settings.Impl(context), SearchSettings { - override var searchFilterMode: MusicMode? + override var filterTo: MusicType? get() = - MusicMode.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_search_filter), Int.MIN_VALUE)) + MusicType.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_search_filter_to), Int.MIN_VALUE)) set(value) { sharedPreferences.edit { - putInt(getString(R.string.set_key_search_filter), value?.intCode ?: Int.MIN_VALUE) + putInt( + getString(R.string.set_key_search_filter_to), value?.intCode ?: Int.MIN_VALUE) apply() } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 8a3aa5a1c..fb60d7ff9 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -32,12 +32,13 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD @@ -63,9 +64,9 @@ constructor( val searchResults: StateFlow> get() = _searchResults - /** The [MusicMode] to use when playing a [Song] from the UI. */ - val playbackMode: MusicMode - get() = playbackSettings.inListPlaybackMode + /** The [PlaySong] instructions to use when playing a [Song]. */ + val playWith + get() = playbackSettings.playInListWith init { musicRepository.addUpdateListener(this) @@ -116,12 +117,12 @@ constructor( userLibrary: UserLibrary, query: String ): List { - val filterMode = searchSettings.searchFilterMode + val filter = searchSettings.filterTo val items = - if (filterMode == null) { - // A nulled filter mode means to not filter anything. - logD("No filter mode specified, using entire library") + if (filter == null) { + // A nulled filter type means to not filter anything. + logD("No filter specified, using entire library") SearchEngine.Items( deviceLibrary.songs, deviceLibrary.albums, @@ -129,14 +130,13 @@ constructor( deviceLibrary.genres, userLibrary.playlists) } else { - logD("Filter mode specified, filtering library") + logD("Filter specified, reducing library") SearchEngine.Items( - songs = if (filterMode == MusicMode.SONGS) deviceLibrary.songs else null, - albums = if (filterMode == MusicMode.ALBUMS) deviceLibrary.albums else null, - artists = if (filterMode == MusicMode.ARTISTS) deviceLibrary.artists else null, - genres = if (filterMode == MusicMode.GENRES) deviceLibrary.genres else null, - playlists = - if (filterMode == MusicMode.PLAYLISTS) userLibrary.playlists else null) + songs = if (filter == MusicType.SONGS) deviceLibrary.songs else null, + albums = if (filter == MusicType.ALBUMS) deviceLibrary.albums else null, + artists = if (filter == MusicType.ARTISTS) deviceLibrary.artists else null, + genres = if (filter == MusicType.GENRES) deviceLibrary.genres else null, + playlists = if (filter == MusicType.PLAYLISTS) userLibrary.playlists else null) } val results = searchEngine.search(items, query) @@ -198,35 +198,35 @@ constructor( */ @IdRes fun getFilterOptionId() = - when (searchSettings.searchFilterMode) { - MusicMode.SONGS -> R.id.option_filter_songs - MusicMode.ALBUMS -> R.id.option_filter_albums - MusicMode.ARTISTS -> R.id.option_filter_artists - MusicMode.GENRES -> R.id.option_filter_genres - MusicMode.PLAYLISTS -> R.id.option_filter_playlists + when (searchSettings.filterTo) { + MusicType.SONGS -> R.id.option_filter_songs + MusicType.ALBUMS -> R.id.option_filter_albums + MusicType.ARTISTS -> R.id.option_filter_artists + MusicType.GENRES -> R.id.option_filter_genres + MusicType.PLAYLISTS -> R.id.option_filter_playlists // Null maps to filtering nothing. null -> R.id.option_filter_all } /** - * Update the filter mode with the newly-selected filter option. + * Update the filter type with the newly-selected filter option. * * @return A menu item ID of the new filtering option selected. */ fun setFilterOptionId(@IdRes id: Int) { - val newFilterMode = + val newFilter = when (id) { - R.id.option_filter_songs -> MusicMode.SONGS - R.id.option_filter_albums -> MusicMode.ALBUMS - R.id.option_filter_artists -> MusicMode.ARTISTS - R.id.option_filter_genres -> MusicMode.GENRES - R.id.option_filter_playlists -> MusicMode.PLAYLISTS + R.id.option_filter_songs -> MusicType.SONGS + R.id.option_filter_albums -> MusicType.ALBUMS + R.id.option_filter_artists -> MusicType.ARTISTS + R.id.option_filter_genres -> MusicType.GENRES + R.id.option_filter_playlists -> MusicType.PLAYLISTS // Null maps to filtering nothing. R.id.option_filter_all -> null else -> error("Invalid option ID provided") } - logD("Updating filter mode to $newFilterMode") - searchSettings.searchFilterMode = newFilterMode + logD("Updating filter type to $newFilter") + searchSettings.filterTo = newFilter search(lastQuery) } 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 8288d7443..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,75 +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") - @Suppress("DEPRECATION") - 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/settings/ui/IntListPreferenceDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt index 2995b6353..67ad6b1f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.util.fixDoubleRipple class IntListPreferenceDialog : PreferenceDialogFragmentCompat() { private val listPreference: IntListPreference get() = (preference as IntListPreference) + private var pendingValueIndex = -1 override fun onStart() { diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt index 9937d1eac..0299a6af8 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt @@ -76,6 +76,7 @@ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: // Enable experimental settings that allow us to skip the half-expanded state. override fun shouldSkipHalfExpandedStateWhenDragging() = true + override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) = true 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 new file mode 100644 index 000000000..8abffb38f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2022 Auxio Project + * ViewBindingBottomSheetDialogFragment.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.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StyleRes +import androidx.fragment.app.DialogFragment +import androidx.viewbinding.ViewBinding +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 + +/** + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * [BottomSheetDialogFragment]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ViewBindingBottomSheetDialogFragment : + BackportBottomSheetDialogFragment() { + private var _binding: VB? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog = + TweakedBottomSheetDialog(requireContext(), theme) + + /** + * Inflate the [ViewBinding] during [onCreateView]. + * + * @param inflater The [LayoutInflater] to inflate the [ViewBinding] with. + * @return A new [ViewBinding] instance. + * @see onCreateView + */ + protected abstract fun onCreateBinding(inflater: LayoutInflater): VB + + /** + * Configure the newly-inflated [ViewBinding] during [onViewCreated]. + * + * @param binding The [ViewBinding] to configure. + * @param savedInstanceState The previously saved state of the UI. + * @see onViewCreated + */ + protected open fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {} + + /** + * Free memory held by the [ViewBinding] during [onDestroyView] + * + * @param binding The [ViewBinding] to release. + * @see onDestroyView + */ + protected open fun onDestroyBinding(binding: VB) {} + + /** The [ViewBinding], or null if it has not been inflated yet. */ + protected val binding: VB? + get() = _binding + + /** + * Get the [ViewBinding] under the assumption that it has been inflated. + * + * @return The currently-inflated [ViewBinding]. + * @throws IllegalStateException if the [ViewBinding] is not inflated. + */ + protected fun requireBinding() = + requireNotNull(_binding) { + "ViewBinding was available. Fragment should be a valid state " + + "right now, but instead it was ${lifecycle.currentState}" + } + + final override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = onCreateBinding(inflater).also { _binding = it }.root + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onBindingCreated(requireBinding(), savedInstanceState) + logD("Fragment created") + } + + final override fun onDestroyView() { + super.onDestroyView() + onDestroyBinding(unlikelyToBeNull(_binding)) + // Clear binding + _binding = null + logD("Fragment destroyed") + } + + private inner class TweakedBottomSheetDialog + @JvmOverloads + constructor(context: Context, @StyleRes theme: Int = 0) : + BackportBottomSheetDialog(context, theme) { + private var avoidUnusableCollapsedState = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 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() + 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/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt rename to app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt index 1615cbfd3..f12fe1b2e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingMaterialDialogFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2022 Auxio Project - * ViewBindingDialogFragment.kt is part of Auxio. + * ViewBindingMaterialDialogFragment.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 @@ -32,11 +32,12 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle. + * A lifecycle-aware [DialogFragment] that automatically manages the [ViewBinding] lifecycle as a + * material dialog. * * @author Alexander Capehart (OxygenCobalt) */ -abstract class ViewBindingDialogFragment : DialogFragment() { +abstract class ViewBindingMaterialDialogFragment : DialogFragment() { private var _binding: VB? = null /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt index de03d3ba9..60ca375c0 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt @@ -22,7 +22,7 @@ import android.os.Build import org.oxycblt.auxio.R import org.oxycblt.auxio.util.logW -private val ACCENT_NAMES = +private val accentNames = intArrayOf( R.string.clr_red, R.string.clr_pink, @@ -42,7 +42,7 @@ private val ACCENT_NAMES = R.string.clr_grey, R.string.clr_dynamic) -private val ACCENT_THEMES = +private val accentThemes = intArrayOf( R.style.Theme_Auxio_Red, R.style.Theme_Auxio_Pink, @@ -63,7 +63,7 @@ private val ACCENT_THEMES = R.style.Theme_Auxio_App // Dynamic colors are on the base theme ) -private val ACCENT_BLACK_THEMES = +private val accentBlackThemes = intArrayOf( R.style.Theme_Auxio_Black_Red, R.style.Theme_Auxio_Black_Pink, @@ -84,7 +84,7 @@ private val ACCENT_BLACK_THEMES = R.style.Theme_Auxio_Black // Dynamic colors are on the base theme ) -private val ACCENT_PRIMARY_COLORS = +private val accentPrimaryColors = intArrayOf( R.color.red_primary, R.color.pink_primary, @@ -115,18 +115,18 @@ private val ACCENT_PRIMARY_COLORS = class Accent private constructor(val index: Int) { /** The name of this [Accent]. */ val name: Int - get() = ACCENT_NAMES[index] + get() = accentNames[index] /** The theme resource for this accent. */ val theme: Int - get() = ACCENT_THEMES[index] + get() = accentThemes[index] /** * The black theme resource for this accent. Identical to [theme], but with a black background. */ val blackTheme: Int - get() = ACCENT_BLACK_THEMES[index] + get() = accentBlackThemes[index] /** The accent's primary color. */ val primary: Int - get() = ACCENT_PRIMARY_COLORS[index] + get() = accentPrimaryColors[index] override fun equals(other: Any?) = other is Accent && index == other.index @@ -152,7 +152,7 @@ class Accent private constructor(val index: Int) { val DEFAULT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Use dynamic coloring on devices that support it. - ACCENT_THEMES.lastIndex + accentThemes.lastIndex } else { // Use blue everywhere else. 5 @@ -161,10 +161,10 @@ class Accent private constructor(val index: Int) { /** The amount of valid accents. */ val MAX = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - ACCENT_THEMES.size + accentThemes.size } else { // Disable the option for a dynamic accent on unsupported devices. - ACCENT_THEMES.size - 1 + accentThemes.size - 1 } } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index a09e0f0d5..c5ce7a5cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -29,18 +29,18 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.ui.UISettings -import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [ViewBindingDialogFragment] that allows the user to configure the current [Accent]. + * A [ViewBindingMaterialDialogFragment] that allows the user to configure the current [Accent]. * * @author Alexander Capehart (OxygenCobalt) */ @AndroidEntryPoint class AccentCustomizeDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingMaterialDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) @Inject lateinit var uiSettings: UISettings 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 d06c0ca37..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,24 +18,34 @@ 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 import android.view.View import android.view.WindowInsets 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 import androidx.recyclerview.widget.GridLayoutManager 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 @@ -103,6 +113,25 @@ val Drawable.isRtl: Boolean val ViewBinding.context: Context get() = root.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 Toolbar.overrideOnOverflowMenuClick(block: (View) -> Unit) { + for (toolbarChild in children) { + if (toolbarChild is ActionMenuView) { + for (menuChild in toolbarChild.children) { + // The overflow menu's view implementation is package-private, so test for the + // first child that isn't a plain action button. + if (menuChild !is ActionMenuItemView) { + menuChild.setOnClickListener(block) + return + } + } + } + } +} + /** * Compute if this [RecyclerView] can scroll through their items, or if the items can all fit on one * screen. @@ -298,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 3ad2f8eb1..8b8d8c6a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -41,21 +41,21 @@ fun unlikelyToBeNull(value: T?) = * * @return The given number if it's non-zero, null otherwise. */ -fun Int.nonZeroOrNull() = if (this > 0) this else null +fun Int.positiveOrNull() = if (this > 0) this else null /** * Aliases a check to ensure that the given number is non-zero. * * @return The same number if it's non-zero, null otherwise. */ -fun Long.nonZeroOrNull() = if (this > 0) this else null +fun Long.positiveOrNull() = if (this > 0) this else null /** * Aliases a check to ensure that the given number is non-zero. * * @return The same number if it's non-zero, null otherwise. */ -fun Float.nonZeroOrNull() = if (this > 0) this else null +fun Float.nonZeroOrNull() = if (this != 0f) this else null /** * Aliases a check to ensure a given value is in a specified range. @@ -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..bc1197af4 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") +inline 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) { +inline fun logD(msg: String) { if (BuildConfig.DEBUG && !copyleftNotice()) { - Log.d(autoTag, msg) + Timber.d(msg) } } @@ -47,31 +44,24 @@ fun Any.logD(msg: String) { * * @param msg The message to log. */ -fun Any.logW(msg: String) = Log.w(autoTag, msg) +inline 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"}" +inline 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 * source open. */ @Suppress("KotlinConstantConditions") -private fun copyleftNotice(): Boolean { +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/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index c1e1a4a92..eb74d8f15 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -52,6 +52,7 @@ interface Event { */ class MutableEvent : Event { override val flow = MutableStateFlow(null) + override fun consume() = flow.value?.also { flow.value = null } /** diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 451dcabd7..e6f86736a 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -137,14 +137,19 @@ constructor( // Respond to all major song or player changes that will affect the widget override fun onIndexMoved(queue: Queue) = update() + override fun onQueueReordered(queue: Queue) = update() + override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() + override fun onStateChanged(state: InternalPlayer.State) = update() + override fun onRepeatChanged(repeatMode: RepeatMode) = update() // Respond to settings changes that will affect the widget override fun onRoundModeChanged() = update() - override fun onCoverModeChanged() = update() + + override fun onImageSettingsChanged() = update() /** * A condensed form of the playback state that is safe to use in AppWidgets. diff --git a/app/src/main/res/anim/bottom_sheet_slide_in.xml b/app/src/main/res/anim/bottom_sheet_slide_in.xml new file mode 100644 index 000000000..e9236ec2d --- /dev/null +++ b/app/src/main/res/anim/bottom_sheet_slide_in.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/anim/bottom_sheet_slide_out.xml b/app/src/main/res/anim/bottom_sheet_slide_out.xml new file mode 100644 index 000000000..3337807ab --- /dev/null +++ b/app/src/main/res/anim/bottom_sheet_slide_out.xml @@ -0,0 +1,9 @@ + + + + + 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/drawable/ic_details_24.xml b/app/src/main/res/drawable/ic_details_24.xml new file mode 100644 index 000000000..525ec5618 --- /dev/null +++ b/app/src/main/res/drawable/ic_details_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_play_next.xml b/app/src/main/res/drawable/ic_play_next.xml deleted file mode 100644 index 6a3d37c13..000000000 --- a/app/src/main/res/drawable/ic_play_next.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_play_next_24.xml b/app/src/main/res/drawable/ic_play_next_24.xml new file mode 100644 index 000000000..df1a8dd79 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_next_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_playing_indicator_24.xml b/app/src/main/res/drawable/ic_playing_indicator_24.xml index 63c270ac1..4a74acba0 100644 --- a/app/src/main/res/drawable/ic_playing_indicator_24.xml +++ b/app/src/main/res/drawable/ic_playing_indicator_24.xml @@ -3,9 +3,10 @@ xmlns:aapt="http://schemas.android.com/aapt"> diff --git a/app/src/main/res/drawable/ic_playlist_add_24.xml b/app/src/main/res/drawable/ic_playlist_add_24.xml new file mode 100644 index 000000000..6d0cfca3f --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_queue_add_24.xml b/app/src/main/res/drawable/ic_queue_add_24.xml new file mode 100644 index 000000000..a9e7e0ac3 --- /dev/null +++ b/app/src/main/res/drawable/ic_queue_add_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share_24.xml b/app/src/main/res/drawable/ic_share_24.xml new file mode 100644 index 000000000..17485830b --- /dev/null +++ b/app/src/main/res/drawable/ic_share_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_splash_anim.xml b/app/src/main/res/drawable/ic_splash_anim.xml index 74568da64..d71c04457 100644 --- a/app/src/main/res/drawable/ic_splash_anim.xml +++ b/app/src/main/res/drawable/ic_splash_anim.xml @@ -20,7 +20,7 @@ @@ -62,8 +62,7 @@ app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - tools:text="Album Name" /> - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> + + + + + + + + + + + \ No newline at end of file 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 e27526de6..abc8d7e01 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:menu="@menu/menu_playback" + app:menu="@menu/toolbar_playback" app:navigationIcon="@drawable/ic_down_24" app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> @@ -44,33 +44,18 @@ android:id="@+id/playback_artist" style="@style/Widget.Auxio.TextView.Secondary.Marquee" android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_medium" - android:layout_marginEnd="@dimen/spacing_medium" - app:layout_constraintBottom_toTopOf="@+id/playback_album" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - tools:text="Artist Name" /> - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp-land/fragment_main.xml b/app/src/main/res/layout-w600dp-land/fragment_main.xml index e7213924f..e381eae0f 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_main.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_main.xml @@ -13,7 +13,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" - app:navGraph="@navigation/main" + app:navGraph="@navigation/inner" app:defaultNavHost="true" tools:layout="@layout/fragment_home" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 6064ea0e4..23d23770a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -3,8 +3,10 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_host" - android:name="org.oxycblt.auxio.MainFragment" + android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurface" + app:defaultNavHost="true" + app:navGraph="@navigation/outer" tools:layout="@layout/fragment_main" /> \ No newline at end of file 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/dialog_menu.xml b/app/src/main/res/layout/dialog_menu.xml new file mode 100644 index 000000000..1a5a7a605 --- /dev/null +++ b/app/src/main/res/layout/dialog_menu.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index 49458926a..fa48b8a9c 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -27,7 +27,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/spacing_large" - android:layout_marginTop="@dimen/spacing_small" + android:layout_marginTop="@dimen/spacing_tiny" android:layout_marginEnd="@dimen/spacing_large" android:gravity="center" app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header" diff --git a/app/src/main/res/layout/dialog_sort.xml b/app/src/main/res/layout/dialog_sort.xml new file mode 100644 index 000000000..bf6ba7511 --- /dev/null +++ b/app/src/main/res/layout/dialog_sort.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index a272ca07f..d3a9409da 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -24,6 +24,7 @@ android:layout_height="wrap_content" android:clickable="true" android:focusable="true" + app:menu="@menu/toolbar_detail" app:navigationIcon="@drawable/ic_back_24" /> + app:menu="@menu/toolbar_selection" /> + app:menu="@menu/toolbar_edit" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 712509a65..f1b5c8c80 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_scrollFlags="scroll|enterAlways" - app:menu="@menu/menu_home" + app:menu="@menu/toolbar_home" app:title="@string/info_app_name" /> + app:menu="@menu/toolbar_selection" /> @@ -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 81141391f..98328654d 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,7 +14,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentBehavior" - app:navGraph="@navigation/main" + app:navGraph="@navigation/inner" app:defaultNavHost="true" tools:layout="@layout/fragment_home" /> @@ -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" /> @@ -24,56 +24,17 @@ app:enablePlaybackIndicator="false" app:enableSelectionBadge="false" app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" app:layout_constraintVertical_chainStyle="packed" /> - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_preferences.xml b/app/src/main/res/layout/fragment_preferences.xml index e4fc1dd98..a66497d9d 100644 --- a/app/src/main/res/layout/fragment_preferences.xml +++ b/app/src/main/res/layout/fragment_preferences.xml @@ -1,7 +1,6 @@ + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 2aac496d0..d8eba32d7 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -21,7 +21,7 @@ android:id="@+id/search_normal_toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:menu="@menu/menu_search" + app:menu="@menu/toolbar_search" app:navigationIcon="@drawable/ic_back_24"> + app:menu="@menu/toolbar_selection" /> diff --git a/app/src/main/res/layout/item_edit_header.xml b/app/src/main/res/layout/item_edit_header.xml index 80659deca..e3bbb9009 100644 --- a/app/src/main/res/layout/item_edit_header.xml +++ b/app/src/main/res/layout/item_edit_header.xml @@ -28,14 +28,14 @@ app:icon="@drawable/ic_edit_24" app:layout_constraintEnd_toEndOf="parent" /> - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/item_menu_option.xml b/app/src/main/res/layout/item_menu_option.xml new file mode 100644 index 000000000..894882598 --- /dev/null +++ b/app/src/main/res/layout/item_menu_option.xml @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_playback_song.xml b/app/src/main/res/layout/item_playback_song.xml new file mode 100644 index 000000000..3e8c0c6a1 --- /dev/null +++ b/app/src/main/res/layout/item_playback_song.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_sort_mode.xml b/app/src/main/res/layout/item_sort_mode.xml new file mode 100644 index 000000000..7d8129737 --- /dev/null +++ b/app/src/main/res/layout/item_sort_mode.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/album.xml b/app/src/main/res/menu/album.xml new file mode 100644 index 000000000..f560baefc --- /dev/null +++ b/app/src/main/res/menu/album.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/album_song.xml b/app/src/main/res/menu/album_song.xml new file mode 100644 index 000000000..fdbc3fc5f --- /dev/null +++ b/app/src/main/res/menu/album_song.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/artist_album.xml b/app/src/main/res/menu/artist_album.xml new file mode 100644 index 000000000..8c159d9c6 --- /dev/null +++ b/app/src/main/res/menu/artist_album.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/artist_song.xml b/app/src/main/res/menu/artist_song.xml new file mode 100644 index 000000000..803a12785 --- /dev/null +++ b/app/src/main/res/menu/artist_song.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/detail_album.xml b/app/src/main/res/menu/detail_album.xml new file mode 100644 index 000000000..742abdb11 --- /dev/null +++ b/app/src/main/res/menu/detail_album.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_album_actions.xml b/app/src/main/res/menu/detail_parent.xml similarity index 50% rename from app/src/main/res/menu/menu_artist_album_actions.xml rename to app/src/main/res/menu/detail_parent.xml index a39b0127e..a6a1b6d09 100644 --- a/app/src/main/res/menu/menu_artist_album_actions.xml +++ b/app/src/main/res/menu/detail_parent.xml @@ -1,24 +1,27 @@ - + + android:icon="@drawable/ic_play_24" /> + android:icon="@drawable/ic_shuffle_off_24"/> + android:title="@string/lbl_play_next" + android:icon="@drawable/ic_play_next_24"/> + android:title="@string/lbl_queue_add" + android:icon="@drawable/ic_queue_add_24"/> + android:title="@string/lbl_playlist_add" + android:icon="@drawable/ic_playlist_add_24" /> + android:title="@string/lbl_share" + android:icon="@drawable/ic_share_24"/> \ No newline at end of file diff --git a/app/src/main/res/menu/detail_playlist.xml b/app/src/main/res/menu/detail_playlist.xml new file mode 100644 index 000000000..178588c3b --- /dev/null +++ b/app/src/main/res/menu/detail_playlist.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_actions.xml b/app/src/main/res/menu/menu_album_actions.xml deleted file mode 100644 index 6f9f28aff..000000000 --- a/app/src/main/res/menu/menu_album_actions.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_detail.xml b/app/src/main/res/menu/menu_album_detail.xml deleted file mode 100644 index 7cc2b4b79..000000000 --- a/app/src/main/res/menu/menu_album_detail.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_song_actions.xml b/app/src/main/res/menu/menu_album_song_actions.xml deleted file mode 100644 index 7325144c0..000000000 --- a/app/src/main/res/menu/menu_album_song_actions.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_album_sort.xml b/app/src/main/res/menu/menu_album_sort.xml deleted file mode 100644 index 44f9744aa..000000000 --- a/app/src/main/res/menu/menu_album_sort.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_song_actions.xml b/app/src/main/res/menu/menu_artist_song_actions.xml deleted file mode 100644 index 78442df43..000000000 --- a/app/src/main/res/menu/menu_artist_song_actions.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_artist_sort.xml b/app/src/main/res/menu/menu_artist_sort.xml deleted file mode 100644 index 50ba847ba..000000000 --- a/app/src/main/res/menu/menu_artist_sort.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_genre_sort.xml b/app/src/main/res/menu/menu_genre_sort.xml deleted file mode 100644 index ad5d920c8..000000000 --- a/app/src/main/res/menu/menu_genre_sort.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml deleted file mode 100644 index 278206a03..000000000 --- a/app/src/main/res/menu/menu_home.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_parent_actions.xml b/app/src/main/res/menu/menu_parent_actions.xml deleted file mode 100644 index 6de2527de..000000000 --- a/app/src/main/res/menu/menu_parent_actions.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_parent_detail.xml b/app/src/main/res/menu/menu_parent_detail.xml deleted file mode 100644 index e73829b41..000000000 --- a/app/src/main/res/menu/menu_parent_detail.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playback.xml b/app/src/main/res/menu/menu_playback.xml deleted file mode 100644 index 92264d881..000000000 --- a/app/src/main/res/menu/menu_playback.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_actions.xml b/app/src/main/res/menu/menu_playlist_actions.xml deleted file mode 100644 index 6a165da6a..000000000 --- a/app/src/main/res/menu/menu_playlist_actions.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_detail.xml b/app/src/main/res/menu/menu_playlist_detail.xml deleted file mode 100644 index 666629234..000000000 --- a/app/src/main/res/menu/menu_playlist_detail.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_playlist_song_actions.xml b/app/src/main/res/menu/menu_playlist_song_actions.xml deleted file mode 100644 index e55d8e3f6..000000000 --- a/app/src/main/res/menu/menu_playlist_song_actions.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_selection_actions.xml b/app/src/main/res/menu/menu_selection_actions.xml deleted file mode 100644 index e596b97a6..000000000 --- a/app/src/main/res/menu/menu_selection_actions.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_song_actions.xml b/app/src/main/res/menu/menu_song_actions.xml deleted file mode 100644 index b892ba7c3..000000000 --- a/app/src/main/res/menu/menu_song_actions.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/parent.xml b/app/src/main/res/menu/parent.xml new file mode 100644 index 000000000..cae1e0c53 --- /dev/null +++ b/app/src/main/res/menu/parent.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/playback_song.xml b/app/src/main/res/menu/playback_song.xml new file mode 100644 index 000000000..2cfc524b2 --- /dev/null +++ b/app/src/main/res/menu/playback_song.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/playlist.xml b/app/src/main/res/menu/playlist.xml new file mode 100644 index 000000000..af3277d8a --- /dev/null +++ b/app/src/main/res/menu/playlist.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/playlist_song.xml b/app/src/main/res/menu/playlist_song.xml new file mode 100644 index 000000000..934d8b514 --- /dev/null +++ b/app/src/main/res/menu/playlist_song.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/selection.xml b/app/src/main/res/menu/selection.xml new file mode 100644 index 000000000..1d4f3d94d --- /dev/null +++ b/app/src/main/res/menu/selection.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/song.xml b/app/src/main/res/menu/song.xml new file mode 100644 index 000000000..d82272d49 --- /dev/null +++ b/app/src/main/res/menu/song.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar_detail.xml b/app/src/main/res/menu/toolbar_detail.xml new file mode 100644 index 000000000..a827b708e --- /dev/null +++ b/app/src/main/res/menu/toolbar_detail.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_edit_actions.xml b/app/src/main/res/menu/toolbar_edit.xml similarity index 100% rename from app/src/main/res/menu/menu_edit_actions.xml rename to app/src/main/res/menu/toolbar_edit.xml diff --git a/app/src/main/res/menu/toolbar_home.xml b/app/src/main/res/menu/toolbar_home.xml new file mode 100644 index 000000000..9aa0360de --- /dev/null +++ b/app/src/main/res/menu/toolbar_home.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar_playback.xml b/app/src/main/res/menu/toolbar_playback.xml new file mode 100644 index 000000000..27791e20a --- /dev/null +++ b/app/src/main/res/menu/toolbar_playback.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/toolbar_search.xml similarity index 100% rename from app/src/main/res/menu/menu_search.xml rename to app/src/main/res/menu/toolbar_search.xml diff --git a/app/src/main/res/menu/toolbar_selection.xml b/app/src/main/res/menu/toolbar_selection.xml new file mode 100644 index 000000000..e1cf43ef0 --- /dev/null +++ b/app/src/main/res/menu/toolbar_selection.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml new file mode 100644 index 000000000..43c0d52c3 --- /dev/null +++ b/app/src/main/res/navigation/inner.xml @@ -0,0 +1,531 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml deleted file mode 100644 index b1694965c..000000000 --- a/app/src/main/res/navigation/main.xml +++ /dev/null @@ -1,388 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/outer.xml b/app/src/main/res/navigation/outer.xml new file mode 100644 index 000000000..b8198339e --- /dev/null +++ b/app/src/main/res/navigation/outer.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 50e2518d2..011240291 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -1,7 +1,7 @@ - مشغل موسيقى بسيط ومعقول لنظام الاندرويد. + مشغل موسيقى بسيط ومعقول للأندرويد. عرض وتحكم بشتغيل الموسيقى إعادة المحاولة @@ -23,15 +23,15 @@ يتم التشغيل الان تشغيل عشوائي - تشغيل من جميع الاغاني - تشغيل من البوم - تشغيل من فنان + تشغيل من جميع الاغاني + تشغيل من البوم + تشغيل من فنان طابور شغل الاغنية التالية أضف إلى الطابور تمت الإضافة إلى الطابور - أذهب إلى الفنان - أذهب إلى الالبوم + أذهب إلى الفنان + أذهب إلى الالبوم تم حفظ الحالة أضف حفظ @@ -40,7 +40,7 @@ الإصدار عرض على الكود في Github التراخيص - تمت برمجة التطبيق من قبل OxygenCobalt + تمت برمجة التطبيق من قبل الكساندر كابيهارت الإعدادات المظهر @@ -63,7 +63,7 @@ تفضيل الالبوم ديناميكي سلوك - عند اختيار اغنية + عند اختيار اغنية تذكر الخلط إبقاء وضع الخلط عند تشغيل اغنية جديدة تشجيع قبل التخطي للخلف @@ -145,8 +145,6 @@ الحجم المسار إحصائيات المكتبة - تشغي الاغاني المحددة بترتيب عشوائي - تشغيل الموسيقى المحددة معدل البت اسم الملف تجميع مباشر @@ -157,7 +155,7 @@ تشغيل كل الاغاني بشكل عشوائي حسنا اعادة الحالة - تنازلي + تنازلي عرض الخصائص مسح الحالة مباشر @@ -193,4 +191,28 @@ التحويل البرمجي مزيج Wiki + أغنية + أتجاه + أختيار + قوائم التشغيل + قائمة التشغيل + تم خلق قائمة التشغيل + المزيد + حذف + تم النسخ + إضافة إلى قائمة التشغيل + مشاركة + تعديل + إعادة التسمية + تمت الإضافة إلى قائمة التشغيل + رتب حسب + مشاهدة + حذف قائمة التشغيل؟ + تم حذف قائمة التشغيل + تقرير + قائمة تشغيل جديدة + معلومات خاطئة + تم إعادة تسمية قائمة التشغيل + إعادة تسمية قائمة التشغيل + يظهر على \ No newline at end of file diff --git a/app/src/main/res/values-ar-rSA/strings.xml b/app/src/main/res/values-ar-rSA/strings.xml new file mode 100644 index 000000000..3a0906840 --- /dev/null +++ b/app/src/main/res/values-ar-rSA/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 000000000..ca6f6fafd --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,83 @@ + + + مشغّل موسيقى بسيط ومعقول للأندرويد + مراقبة مكتبة الموسيقى + إعادة المحاولة + منح + الألبومات + أغاني + أغنية + كل الأغاني + ألبوم + ألبوم مباشر + تجميعات + حذف قائمة التشغيل؟ + بحث + تصفية + تشغيل التالي + إضافة للطابور + إضافة لقائمة التشغيل + إعادة ضبط + إضافة مجلد + تم حفظ الحالة + يتم تحميل مكتبتك الموسيقية + أضيفت للطابور + تم إنشاء قائمة التشغيل + فنانون + قائمة تشغيل جديدة + إعادة تسمية قائمة التشغيل + تعديل + طابور + خلط + اذهب للفنان + عرض والتحكم في تشغيل الموسيقى + خلط + اسم الملف + خلط الكل + إلغاء + حفظ + تتم مراقبة التغييرات في مكتبك الموسيقية… + تجميعة + تجميعة مباشرة + مباشر + ظهر فيه + فنان + ريميكسات + نوع + أنواع + قائمة تشغيل + قوائم تشغيل + إعادة تسمية + حذف + الكل + الاسم + التاريخ + المدة + عدد الأغاني + القرص + المسار + تاريخ الإضافة + فرز + تصاعدياً + تنازلياً + يتم الآن تشغيل + المُعادِل + تشغيل + اذهب للألبوم + عرض الخصائص + عرض + مشاركة + خصائص الأغنية + التنسيق + الحجم + معدل البِت + موافق + تم حذف الحالة + تمت استعادة الحالة + حول + الإصدار + شفرة المصدر + الموسوعة + التراخيص + إحصائيات المكتبة + \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index ed22aa75f..a17118fb0 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -12,7 +12,7 @@ Дададзены ў чаргу Распрацавана Аляксандрам Кейпхартам Карыстальніцкае дзеянне панэлі прайгравання - Прайграць з альбома + Прайграць з альбома Коска (,) Плюс (+) Амперсанд (&) @@ -72,19 +72,17 @@ Зараз іграе Гуляць Ператасаваць - Выбрана перамешванне Памер Ператасаваць Адмяніць Па ўзрастанні - Па змяншэнні + Па змяншэнні Гуляць далей Дадаць у чаргу Эквалайзер - Гуляць выбрана Чарга - Перайсці да альбома - Перайсці да выканаўцы + Перайсці да альбома + Перайсці да выканаўцы Імя файла Праглядзіце ўласцівасці Уласцівасці песні @@ -234,15 +232,15 @@ Перайсці да наступнага Рэжым паўтору Паводзіны - Пры прайграванні з бібліятэкі - Прайграць усе песні + Пры прайграванні з бібліятэкі + Прайграць усе песні Кіруйце загрузкай музыкі і малюнкаў Карыстальніцкае дзеянне апавяшчэння - Пры прайграванні з дэталяў прадмета - Гуляць з паказанага прадмета + Пры прайграванні з дэталяў прадмета + Гуляць з паказанага прадмета Ігнаруйце аўдыяфайлы, якія не з\'яўляюцца музыкай, напрыклад, падкасты - Гуляць ад выканаўцы - Гуляць з жанру + Гуляць ад выканаўцы + Гуляць з жанру Запамінаць перамешванне Уключайце перамешванне падчас прайгравання новай песні Кантэнт @@ -295,4 +293,18 @@ Рэдагаванне %s Выкарыстоўваць квадратныя вокладкі альбомаў Абрэзаць усе вокладкі альбомаў да суадносін бакоў 1:1 + Песня + Прайграць песню самастойна + Выгляд + Сартаваць па + Напрамак + Абярыце малюнак + Абярыце + Дадаткова + Скапіравана + Інфармацыя пра памылку + Справаздача пра памылку + Няма альбомаў + Дэма + Дэманстрацыі \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6abd46909..e39a84ca3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -29,15 +29,15 @@ Právě hraje Přehrát Náhodně - Přehrát ze všech skladeb - Přehrát z alba - Přehrát od umělce + Přehrát ze všech skladeb + Přehrát z alba + Přehrát od umělce Fronta Přehrát další Přidat do fronty Přidáno do fronty - Přejít na umělce - Přejít na album + Přejít na umělce + Přejít na album Stav uložen OK @@ -80,7 +80,7 @@ Přizpůsobení bez štítků Varování: Změna předzesilovače na vysokou kladnou hodnotu může u některých zvukových stop vést k příliš vysokým hlasitostem. Chování - Při přehrávání z knihovny + Při přehrávání z knihovny Zapamatovat si náhodné přehrávání Ponechat náhodné přehrávání při přehrávání nové skladby Přetočit před přeskočením zpět @@ -180,9 +180,9 @@ Free Lossless Audio Codec (FLAC) %d kbps %d Hz - Při přehrávání z podrobností o položce + Při přehrávání z podrobností o položce Spravovat, odkud by měla být načítána hudba - Přehrát ze zobrazené položky + Přehrát ze zobrazené položky Zobrazit vlastnosti Vlastnosti skladby Název souboru @@ -260,10 +260,8 @@ 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 + Přehrát z žánru Wiki %1$s, %2$s Obnovit @@ -279,7 +277,7 @@ Přehrávání Knihovna Perzistence - Sestupně + Sestupně Seznamy skladeb Obrázek seznamu skladeb pro %s Seznam skladeb @@ -306,4 +304,18 @@ Sdílet Vynutit čtvercové obaly alb Oříznout všechny covery alb na poměr stran 1:1 + Skladba + Zobrazit + Přehrát skladbu samostatně + Směr + Seřadit podle + Výběr obrázku + Výběr + Další + Informace o chybě + Zkopírovat + Nahlásit + Žádná alba + Demo + Dema \ 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 2f944921a..4c0fa5b55 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -15,15 +15,15 @@ Aufsteigend Abspielen Zufällig - Von allen Lieder abspielen - Von Album abspielen + Von allen Lieder abspielen + Von Album abspielen Aktuelle Wiedergabe Warteschlange Als Nächstes abspielen Zur Warteschlange hinzufügen Der Warteschlange hinzugefügt - Zum Künstler gehen - Zum Album gehen + Zum Künstler gehen + Zum Album gehen Wiedergabezustand gespeichert Hinzufügen Speichern @@ -56,11 +56,11 @@ Titel bevorzugen Album bevorzugen Personalisieren - Wenn ein Lied aus der Bibliothek abgespielt wird + Wenn ein Lied aus der Bibliothek abgespielt wird Zufällig-Einstellung merken Zufällig anlassen, wenn ein neues Lied abgespielt wird Zurückspulen, bevor das Lied zurück geändert wird - Zurückspulen, bevor zum vorheriger Lied gewechselt wird + Zurückspulen, bevor zum vorherigen Lied gewechselt wird Inhalt Wiedergabezustand speichern Den aktuellen Wiedergabezustand speichern @@ -153,8 +153,8 @@ Gesamtdauer: %s Abbrechen Warnung: Das Erhöhen der Vorverstärkung zu einem hohen positiven Wert könnte zu einer Übersteuerung bei einigen Audiospuren führen. - Wenn ein Lied aus den Elementdetails abgespielt wird - Vom dargestellten Element abspielen + Wenn ein Lied aus den Elementdetails abgespielt wird + Vom dargestellten Element abspielen Musikordner Verwalten, von wo die Musik geladen werden soll Modus @@ -233,7 +233,7 @@ Komma (,) Schrägstrich (/) Plus (+) - Vom Künstler abspielen + Vom Künstler abspielen Achtung: Verwenden dieser Einstellung könnte dazu führen, dass einige Tags fälschlicherweise interpretiert werden, als hätten sie mehrere Werte. Das kann gelöst werden, in dem vor ungewollte Trenner ein Backslash (\\) eingefügt wird. Nicht-Musik ausschließen Audio-Dateien, die keine Musik sind (wie Podcasts), ignorieren @@ -251,10 +251,8 @@ 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 + Vom Genre abspielen Wiki %1$s, %2$s Zurücksetzen @@ -270,7 +268,7 @@ Ton und Wiedergabeverhalten konfigurieren Persistenz Lautstärkeanpassung ReplayGain - Absteigend + Absteigend Wiedergabelistenbild für %s Wiedergabeliste Wiedergabelisten @@ -297,4 +295,18 @@ %s bearbeiten Quadratische Album-Cover erzwingen Alle Album-Cover auf ein Seitenverhältnis von 1:1 zuschneiden + Lied + Ansehen + Lied selbst spielen + Richtung + Sortieren nach + Auswahl-Bild + Auswahl + Mehr + Kopiert + Melden + Fehlerinformation + Keine Alben + Demo + Demos \ 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 66749ed55..b5730db8e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -19,8 +19,8 @@ Επόμενο Προσθήκη στην ουρά αναπ/γής Προστέθηκε ένας τίτλος στην ουρά αναπαραγωγής - Πήγαινε στον καλλιτέχνη - Πήγαινε στο άλμπουμ + Πήγαινε στον καλλιτέχνη + Πήγαινε στο άλμπουμ Σχετικά με Έκδοση Πηγαίος κώδικας @@ -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 9fd873733..20af977ec 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -14,7 +14,7 @@ Buscar Filtrar Todo - Organizar + Ordenar Nombre Artista Álbum @@ -23,15 +23,15 @@ En reproducción Reproducir Mezcla - Reproducir todo - Reproducir por álbum - Reproducir por artista + Reproducir todo + Reproducir por álbum + Reproducir por artista Cola Reproducir siguiente Agregar a la cola Agregado a la cola - Ir al artista - Ir al álbum + Ir al artista + Ir al álbum Estado guardado Agregar Guardar @@ -43,7 +43,7 @@ Desarrollado por Alexander Capehart Ajustes - Aspecto y sensación + Aspecto y Comportamiento Tema Automático Claro @@ -52,31 +52,31 @@ Tema negro Usar un tema completamente negro Pantalla - Pestañas de biblioteca + Pestañas de la biblioteca Cambiar visibilidad y orden de las pestañas de la biblioteca Carátulas redondeadas - Habilite las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) - Usar acciones de notificación alternativas + Habilitar las esquinas redondeadas en los elementos adicionales de la interfaz del usuario (requiere que las portadas de los álbumes estén redondeadas) + Usar acciones de notificación personalizadas Sonido Estrategia de la ganancia de la repetición - Por pista - Por álbum + Preferir pista + Preferir álbum Preferir el álbum si se está en reproducción - Comportamiento - Cuando se está reproduciendo de la biblioteca + Personalizar + Cuando se está reproduciendo de la biblioteca Recordar mezcla - Mantener mezcla cuando se reproduce una nueva canción - Rebobinar atrás - Rebobinar al saltar a la canción anterior - Pausa en repetición - Pausa cuando se repite una canción + Mantener mezcla activada cuando se reproduce una nueva canción + Rebobinar antes de saltar al anterior + Rebobinar antes de saltar a la canción anterior + Pausar al repetir + Pausar cuando se repite una canción Contenido Guardar estado de reproducción - Guardar el estado de reproduccion ahora + Guardar el estado de reproducción ahora Actualizar música Recargar la biblioteca musical, utilizando las etiquetas en caché cuando sea posible - Sin música + No se ha encontrado música Falló la carga de música Auxio necesita permiso para leer su biblioteca de música No se encontró ninguna aplicación que pueda manejar esta tarea @@ -89,12 +89,12 @@ Saltar a la siguiente canción Saltar a la última canción Cambiar modo de repetición - Act/des mezcla - Mezclar todo + Activar o desactivar mezcla + Mezclar todas las canciones Quitar canción de la cola Mover canción en la cola Mover pestaña - Borrar historial de búsqueda + Borrar búsqueda Quitar carpeta Icono de Auxio Carátula de álbum @@ -149,7 +149,7 @@ Estadísticas de la biblioteca Ajuste sin etiquetas Advertencia: Cambiar el pre-amp a un valor alto puede resultar en picos en algunas pistas de audio. - Reproducir desde el elemento que se muestra + Reproducir desde el elemento que se muestra Modo Excluir La músicano se cargará de las carpetas que añadas. @@ -171,17 +171,17 @@ Monitorizando la librería de música Monitorizando cambios en tu librería de música… Audio ogg - Cuando se reproduce desde los detalles + Cuando se reproduce desde los detalles Fecha de añadido Propiedades de la canción Frecuencia de muestreo Cancelar Reproducción automática con auriculares - Reestablecer el estado de reproducción - Reestablecer el estado de reproducción guardado previamente (si existe) + Restablecer el estado de reproducción + Restablecer el estado de reproducción guardado previamente (si existe) Carpetas de música Gestionar de dónde se cargará la música - La músicasolo se cargará de las carpetas que añadas. + La música solo se cargará de las carpetas que añadas. Dinámico Disco %d Reproducción extendidas (EPs) @@ -193,9 +193,9 @@ Pistas de audio Mixtapes (recopilación de canciones) Mixtape (recopilación de canciones) - Remezcla + Remezclas Nombre de archivo - Siempre empezar la reproducción cuando se conectan unos auriculares (puede no funcionar en todos los dispositivos) + Siempre empezar la reproducción cuando se conecten auriculares (puede no funcionar en todos los dispositivos) Pre-amp ReplayGain El pre-amp se aplica al ajuste existente durante la reproducción Ajuste con etiquetas @@ -206,7 +206,7 @@ Álbum en directo Single en directo Compilación - Directo + En directo Audio MPEG-1 Audio MPEG-4 %d kbps @@ -214,7 +214,7 @@ EP en directo Single remix Compilaciones - EP remix + EP de remixes Directorio superior Eliminar el estado de reproducción guardado previamente (si existe) Abrir la cola @@ -222,15 +222,15 @@ Estado limpiado Limpiar el estado de reproducción Separadores de varios valores - Excluye la música + Excluye los archivos que no sean música Configurar caracteres que denotan múltiples valores de la etiqueta Coma (,) Punto y coma (;) Barra oblicua (/) Recopilación en directo - Compilaciones de remezclas - Mezclas del DJ - Mezclas del DJ + Compilación de remezclas + Mezclas de DJ + Mezcla de DJ Ecualizador Portadas de álbumes Apagado @@ -251,14 +251,12 @@ %d artistas %d artistas - Imposible guardar el estado + No se pudo guardar el estado 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 + Reproducir desde el género Wiki %1$s, %2$s Restablecer @@ -274,7 +272,7 @@ Cambiar el tema y los colores de la aplicación Personalizar los controles y el comportamiento de la interfaz de usuario Biblioteca - Descendente + Descendente Listas de reproducción Imagen de la lista de reproducción para %s Lista de reproducción @@ -299,6 +297,20 @@ Aparece en Compartir Sin disco - Carátula del álbum Force Square + Forzar carátulas de álbum cuadradas Recorta todas las portadas de los álbumes a una relación de aspecto 1:1 + Canción + Vista + Reproducir la canción por sí misma + Ordenar por + Dirección + Selección de imágenes + Selección + Más + Información sobre el error + Copiado + Informar + Sin álbumes + Demostración + Demostraciones \ 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 62053b8a5..262cfde7b 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -33,15 +33,14 @@ Levy Raita Lisäyspäivä - Laskevasti + Laskevasti Nyt toistetaan Taajuuskorjain Toista - Toisto valittu Sekoita Jono Lisää jonoon - Siirry albumiin + Siirry albumiin Näytä ominaisuudet Kappaleen ominaisuudet Tiedostonimi @@ -68,12 +67,12 @@ Muuta kirjastovälilehtien näkyvyyttä ja järjestystä Siirry seuraavaan Kertaustila - Kirjastosta toistettaessa - Kohteen tiedoista toistettaessa + Kirjastosta toistettaessa + Kohteen tiedoista toistettaessa Muista sekoitus - Toista kaikista kappaleista - Toista albumilta - Toista tyylilajista + Toista kaikista kappaleista + Toista albumilta + Toista tyylilajista Moniarvoerottimet Ohita äänitiedostot, jotka eivät ole musiikkia, kuten podcastit Ja-merkki (&) @@ -211,7 +210,7 @@ Nousevasti Toista seuraava - Siirry esittäjään + Siirry esittäjään Sisältö Musiikki Kuvat @@ -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) @@ -238,7 +236,7 @@ Tuntematon tyylilaji Vihreä Musiikkia ei toisteta - Toista esittäjältä + Toista esittäjältä Ohita muu kuin musiikki Palauta aiemmin tallennettu toiston tila (jos olemassa) Musiikkia ei ladata valitsemistasi kansioista. @@ -267,4 +265,13 @@ Siirrä tämä kappale Ei levyä Poistetaanko %s\? Tätä toimenpidettä ei voi perua. + Pakota neliömäiset albumikannet + Lataa musiikkikirjasto uudelleen, käytä välimuistissa olevia tunnisteita kun mahdollista + Rajaa kaikki albumikannet 1:1-suhteeseen + Tyhjennä tunnistevälimuisti ja lataa musiikkikirjasto kokonaan uudelleen (hitaampi mutta kattavampi) + Kappale + Näytä + Lisää + Kopioitu + Ilmoita virheestä \ No newline at end of file diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 17dfdf605..d7e2bdc59 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -42,8 +42,8 @@ Tugtugin pagkatapos Idagdag sa pila Idiinagdag sa pila - Puntahan ang artista - Puntahan ang album + Puntahan ang artista + Puntahan ang album Tignan ang katangian Katangian ng kanta Pangalan ng file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ce20da39c..742007eb0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -20,8 +20,8 @@ Jouer ensuite Ajouter à la file d\'attente Ajouté à la file d\'attente - Aller à l\'album - Aller à l\'artiste + Aller à l\'album + Aller à l\'artiste À propos Version Code source @@ -134,8 +134,6 @@ Genre inconnu Dynamique Cyan - Lecture aléatoire sélectionnée - Réinitialiser Aucun dossier Supprimer le dossier Artiste inconnu @@ -157,7 +155,7 @@ Surveillance de votre bibliothèque musicale pour les changements… Couvertures arrondies Activer les coins arrondis sur des éléments d\'interface utilisateur supplémentaires (nécessite que les couvertures d\'album soient arrondies) - Descendant + Descendant Etat restauré Personnaliser les commandes et le comportement de l\'interface utilisateur Passer au suivant @@ -169,9 +167,9 @@ Recharger la bibliothèque musicale chaque fois qu\'elle change (nécessite une notification persistante) Esperluette (&) Playlist - Lors de la lecture à partir des détails de l\'élément + Lors de la lecture à partir des détails de l\'élément Gardez la lecture aléatoire lors de la lecture d\'une nouvelle chanson - Lire à partir de l\'élément affiché + Lire à partir de l\'élément affiché N\'oubliez pas de mélanger Contrôlez le chargement de la musique et des images Musique @@ -185,18 +183,18 @@ Couvertures originales (téléchargement rapide) Configurer le son et le comportement de lecture Listes de lecture - Lors de la lecture depuis la bibliothèque + Lors de la lecture depuis la bibliothèque Séparateurs multi-valeurs Rechargement automatique - Jouer à partir de toutes les chansons - Jouer de l\'artiste - Jouer à partir du genre + Jouer à partir de toutes les chansons + Jouer de l\'artiste + Jouer à partir du genre Virgule (,) Point-virgule (;) Ignorer les fichiers audio qui ne sont pas de la musique, tels que les podcasts Avertissement : L\'utilisation de ce paramètre peut entraîner l\'interprétation incorrecte de certaines balises comme ayant plusieurs valeurs. Vous pouvez résoudre ce problème en préfixant les caractères de séparation indésirables avec une barre oblique inverse (\\). Exclure non-musique - Lire depuis l\'album + Lire depuis l\'album Barre oblique (/) Plus (+) Vider l\'état de lecture précédemment enregistré (si il existe) @@ -244,7 +242,7 @@ Ajouter à la liste de lecture Créer une nouvelle liste de lecture Audio Matroska - Artistes chargés&nbsp;: %d + Artistes chargés : %d Rembobiner avant de revenir en arrière Image d\'artiste pour %s Aucune piste @@ -255,8 +253,8 @@ Renommer Impossible d\'effacer l\'état Modifier le mode de répétition - Albums chargés&nbsp;: %d - Durée totale&nbsp;: %s + Albums chargés : %d + Durée totale : %s Effacer la requête de recherche Image de la liste de lecture pour %s Disque %d @@ -290,7 +288,7 @@ Impossible de sauvegarder l\'état Aucune chanson Modification de %s - Genres chargés&nbsp;: %d + Genres chargés : %d Image de genre pour %s Codec audio gratuit sans perte (FLAC) %d sélectionnés @@ -299,4 +297,15 @@ %1$s, %2$s Forcer les pochettes d\'album carrées Recadrer toutes les pochettes d\'album au format 1:1 + Chanson + Voir + Jouer la chanson par elle-même + Image de sélection + Trier par + Direction + Sélection + En savoir plus + Copié + Signaler + Info sur l\'erreur \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 022bca385..faf684102 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -8,7 +8,7 @@ Axustes Claro Escuro - Cando se está a reproducir dende a biblioteca + Cando se está a reproducir dende a biblioteca Artista Un reproductor de música simple e racional para android. Sinxelo @@ -32,7 +32,7 @@ Sinxelo en directo Compilación en directo Mesturas - Ir ao álbum + Ir ao álbum Xéneros Xénero Filtro @@ -49,11 +49,10 @@ Reproducir Mezcla Reproducir seguinte - Reproducir a selección Cola Engadir á cola Excluir o que non é música - Ir ao artista + Ir ao artista Nome do arquivo Mesturar Mesturar todo @@ -82,17 +81,17 @@ Recarga a biblioteca de música cando cambia (require unha notificación persistente) Acción da notificación personalizada Saltar ao seguinte - Reproducir dende xénero + Reproducir dende xénero Manter a mestura ao reproducir unha canción nova Contido Modo de repetición Comportamento - Reproducir dende artista + Reproducir dende artista Lembrar a mestura Música - Reproducir dende o elemento que se mostra - Reproducir dende todas as cancións - Reproducir dende álbum + Reproducir dende o elemento que se mostra + Reproducir dende todas as cancións + Reproducir dende álbum Recarga automática Ignorar arquivos de audio que non sexan música, como os pódcasts Todas as cancións @@ -124,9 +123,8 @@ EP en directo EP remix Ascendente - Descendente + Descendente Ecualizador - Aleatorio seleccionado Frecuencia de mostraxe Acerca de Monitorizando cambios na túa biblioteca… @@ -138,7 +136,7 @@ Pestanas de biblioteca Cambiar a visibilidade e a orde das pestanas da biblioteca Acción personalizada da barra de reprodución - Cando se reproduce dende os detalles + Cando se reproduce dende os detalles Controla como se carga a música e as imaxes Separadores de varios valores Configura caracteres que denotan múltiples valores da etiqueta diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2228fd6eb..84be232d8 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -13,8 +13,8 @@ क्रमबद्ध चलाएं शफ़ल - कलाकार पर जाएं - एल्बम पर जाएं + कलाकार पर जाएं + एल्बम पर जाएं पंक्ति क़तार में जोड़ें कतार में जोड़ा गया @@ -63,7 +63,7 @@ अगला चलाएं फ़ाइल का नाम लायब्रेरी टैब्स - एल्बम से चलाएं + एल्बम से चलाएं सामग्री %d चयनित प्रारूप @@ -85,8 +85,8 @@ गतिशील लुक और फील अतिरिक्त UI तत्वों पर गोल कोनों को सक्षम करें (एल्बम कवर को गोल करने की आवश्यकता है) - दिखाए गए आइटम से चलाएँ - लाइब्रेरी से चलाते समय + दिखाए गए आइटम से चलाएँ + लाइब्रेरी से चलाते समय संगीत लाइब्रेरी को फिर से लोड करें जब भी यह बदलता है (स्थाई नोटीफिकेशन की आवश्यकता होती है) ऑडियो फ़ाइलों को अनदेखा करें जो संगीत नहीं हैं, जैसे कि पॉडकास्ट लाइव संकलन @@ -96,12 +96,10 @@ प्लेलिस्ट प्लेलिस्टें गोल मोड - सभी गीतों से चलाएं + सभी गीतों से चलाएं %s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता। लोड किए गए गाने: %d - अवरोही - चयनित चलाएँ - फेरबदल का चयन किया गया + अवरोही स्थिति साफ की गई स्थिति सहेजी गई लायब्रेरी टैब की दृश्यता और क्रम बदलें @@ -109,7 +107,7 @@ UI नियंत्रण और व्यवहार अनुकूलित करें कलाकार लोड किए गए: %d कस्टम प्लेबैक बार एक्शन - आइटम विवरण से चलाते समय + आइटम विवरण से चलाते समय लाइव एल्बम रीमिक्स एल्बम लाइव EP @@ -165,14 +163,14 @@ गीत के गुणधर्म डिस्पले कस्टम नोटीफिकेशन एक्शन - कलाकार से चलाएं - शैली से चलाएं + कलाकार से चलाएं + शैली से चलाएं फेरबदल याद रखें नया गाना बजाते समय फेरबदल करते रहें मल्टी-मूल्य विभाजक लोड की गई शैलियाँ: %d लोड किए गए एल्बम: %d - आपकी संगीत लाइब्रेरी लोड कर रहे हैं... (%1$d/%2$d) + आपकी संगीत लाइब्रेरी लोड कर रहे हैं… (%1$d/%2$d) %d kbps आपकी संगीत लाइब्रेरी लोड कर रहे हैं… प्लेलिस्ट बनाई गई @@ -187,4 +185,127 @@ स्लैश (/) -%.1f dB संपादन %s + पलॅस (+) + ऐंपरसैंड (&) + मोड + एल्बम कवर + %s के लिए एल्बम कवर + नीला हरा + कोई संगीत नहीं बज रहा + अंतिम गीत पर जाएँ + रीप्ले गेन + रीप्ले गेन रणनीति + पिछले गाने को छोड़ने से पहले रिवाइंड करें + %s के लिए शैली छवि + उन्नत ऑडियो कोडिंग (AAC) + गहरा हरा + बैंगनी + संगीत फ़ोल्डर + दोहराने पर विराम + बंद + सभी एल्बम को 1: 1 पहलू अनुपात में कवर करें + प्लेबैक + जब कोई गीत दोहराया जाता है तो रुक जाएं + रीप्लेगेन प्री-एम्प + टैग के साथ समायोजन + फ़ोल्डर + बाहर करें + पर्सिस्टेंस + वर्तमान प्लेबैक स्थिति को अभी सहेजें + पहले से सहेजी गई प्लेबैक स्थिति को पुनर्स्थापित करें (यदि कोई हो) + संगीत लोड करना विफल रहा + यह फ़ोल्डर समर्थित नहीं है + स्थिति पुनर्स्थापित करने में असमर्थ + रिपीट मोड बदलें + शफ़ल चालू या बंद करें + एक नई प्लेलिस्ट बनाएं + सभी गीत शफ़ल करें + प्लेबैक बंद करो + इस गीत को इस स्थानांतरित करें + अज्ञात शैली + ट्रैक नहीं + कोई गीत नहीं + गहरा बैंगनी + इंडिगो + नीला + गहरा नीला + टील + हरा + नारंगी + स्लेटी + %s के लिए प्लेलिस्ट छवि + तिथि नहीं + आपके द्वारा जोड़े गए फ़ोल्डरों से संगीत लोड नहीं किया जाएगा। + संगीत केवल आपके द्वारा जोड़े गए फ़ोल्डरों से लोड किया जाएगा। + प्रबंधित करें कि संगीत कहाँ से लोड किया जाना चाहिए + %s के लिए कलाकार छवि + MPEG-4 ऑडियो + छवियां + एल्बम को प्राथमिकता दें + यदि कुछ बज रहा हो तो एल्बम को प्राथमिकता दें + प्री-एम्प को प्लेबैक के दौरान मौजूदा समायोजन पर लागू किया जाता है + टैग के बिना समायोजन + लाईब्रेरी + संगीत रिफ्रेश करें + संगीत पुनः स्कैन करें + कतार खोलें + लाल + गीत + देखें + चेतावनी: इस सेटिंग का उपयोग करने से कुछ टैग को गलत तरीके से एकाधिक मान के रूप में व्याख्या किया जा सकता है. आप बैकस्लैश (\\) के साथ अवांछित विभाजक वर्णों को उपसर्ग करके इसे हल कर सकते हैं। + केवल उन कलाकारों को दिखाएँ जिन्हें सीधे एल्बम पर श्रेय दिया जाता है (अच्छी तरह से टैग की गई लाइब्रेरी पर अच्छा काम करता है) + तेज + उच्च गुणवत्ता + स्थिति साफ़ करने में असमर्थ + फ़ोल्डर हटाएँ + स्थिती को सहेजने में असमर्थ + यह गीत हटाओ + डिस्क नहीं + निःशुल्क दोषरहित ऑडियो कोडेक (FLAC) + MPEG-1 ऑडियो + हेडसेट ऑटोप्ले + वर्गीकृत एल्बम कवर फोर्स करें + ध्वनि और प्लेबैक व्यवहार कॉन्फ़िगर करें + चेतावनी: प्री-एम्प को उच्च सकारात्मक मान में बदलने से कुछ ऑडियो ट्रैक पर आवाज फट सकती है। + शामिल करें + जब संभव हो तो कैश्ड टैग का उपयोग करके संगीत लाइब्रेरी को पुनः लोड करें + पहले से सहेजी गई प्लेबैक स्थिति साफ़ करें (यदि कोई हो) + कोई ऐप नहीं मिला जो इस कार्य को संभाल सके + अज्ञात कलाकार + इस टैब को स्थानांतरित करें + खोज प्रश्न साफ़ करें + सहयोगियों को छिपाएं + एल्बम कवर + वापस जाने से पहले रिवाइंड करें + ट्रैक को प्राथमिकता दें + टैग कैश साफ़ करें और संगीत लाइब्रेरी को पूरी तरह पुनः लोड करें (धीमी, लेकिन अधिक पूर्ण) + Auxio को आपकी संगीत लाइब्रेरी पढ़ने के लिए अनुमति की आवश्यकता है + कोई फ़ोल्डर नहीं + भूरा + प्लेबैक स्थिति सहेजें + प्लेबैक स्थिति साफ़ करें + प्लेबैक स्थिति पुनर्स्थापित करें + पीला + नींबू रंग + हेडसेट कनेक्ट होने पर हमेशा चलाना शुरू करें (सभी उपकरणों पर काम नहीं करेगा) + ट्रैक %d + अगले गाने पर जाएं + ऑक्सियो आइकन + Ogg ऑडियो + Matroska ऑडियो + गुलाबी + बुद्धिमान छंटाई + संख्याओं या \"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 4809c780e..22bab4d29 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 @@ -82,9 +82,9 @@ Prilagođavanje s oznakama Prilagođavanje bez oznaka Upozorenje: Postavljanje pretpojačala na visoke razine može uzrokovati vrhunce tonova u nekim zvučnim zapisima. - Kada se reproducira iz zbirke - Kada se reproducira iz detalja predmeta - Reproduciraj iz svih pjesama + Kada se reproducira iz zbirke + Kada se reproducira iz detalja predmeta + Reproduciraj iz svih pjesama Aktualiziraj glazbu Ponovo učitaj glazbenu biblioteku, koristeći predmemorirane oznake kada je to moguće Mape glazbe @@ -178,9 +178,9 @@ Sve Dodaj u popis pjesama Dodano u popis pjesama - Prikaži svojstva - Idi na izvođača - Idi na album + Pogledaj svojstva + Idi na izvođača + Idi na album Ostavi miješanje omogućeno kada se druga pjesma reproducira Postavke Tema @@ -191,10 +191,10 @@ Premotaj prije preskakanja natrag Spremi trenutno stanje reprodukcije Preskoči na sljedeću pjesmu - Reproduciraj iz prikazanog predmeta + Reproduciraj iz prikazanog predmeta Zapamti miješanje glazbe Vrati prethodno spremljeno stanje reprodukcije (ako postoji) - Reproduciraj iz albuma + Reproduciraj iz albuma Pauziraj čim se pjesma ponovi Premotaj prije vraćanja na prethodnu pjesmu Reproduciraj ili pauziraj @@ -212,7 +212,7 @@ Otvori popis pjesama Žanr Zarez (,) - Ampersand (&) + Znak i (&) Kompilacija uživo Kompilacija remiksa DJ kompilacije @@ -230,7 +230,7 @@ Sakrij suradnike Isključeno Isključi sve što nije glazba - Reproduciraj iz izvođača + Reproduciraj iz izvođača Visoka kvaliteta Brzo Zanemari sve audio datoteke koje nisu glazba, npr. podcast datoteke @@ -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 + 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,15 @@ 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 + Više + Podaci greške + Prijavi grešku + Kopirano \ 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 8ade1b2d6..c92aa6abf 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -20,8 +20,8 @@ Következő lejátszása Várósorhoz ad Sorbaállítva - Ugrás előadóhoz - Ugrás albumhoz + Ugrás előadóhoz + Ugrás albumhoz Rólunk Verzió Forráskód @@ -74,8 +74,7 @@ Remix EP Név Dátum - Csökkenő - Kiválasztott lejátszása + Csökkenő Új lejátszólista Ismeretlen műfaj Ugrás a következő dalra @@ -86,7 +85,7 @@ Visszajátszás Szülő útvonal Mappa eltávolítása - Playlistához ad + Lejátszólistához ad Formátum Wiki OK @@ -122,13 +121,13 @@ Visszatekerés az előző dalra való ugrás előtt Figyelem: Az előerősítő magas pozitív értékre módosítása egyes hangsávoknál csúcsosodást eredményezhet. Könyvtár - Kitartás + Állapot Lejátszólista Lejátszólisták Töröl Zenelejátszás megtekintése és vezérlése Zenei könyvtár betöltése… - Playlistához adva + Lejátszólistához adva Visszatekerés visszaugrás előtt Ismétlés szünet %s törlése\? Ez nem fordítható vissza. @@ -142,11 +141,10 @@ 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 - Lejátszás albumból + A tétel részleteiből történő lejátszáskor + Lejátszás albumból A zene és a képek betöltésének vezérlése Képek Időtartam @@ -175,8 +173,8 @@ Alaphelyzet Állapot törölve Fejlesztő Alexander Capehart - Lejátszás az összes dalból - Lejátszás műfajból + Lejátszás az összes dalból + Lejátszás műfajból Tartalom A zenei könyvtár újratöltése, ha változik (állandó értesítést igényel) Zene könyvtárak @@ -200,20 +198,20 @@ Állapot törlés nem lehetséges Állapot mentés nem lehetséges Keverés minden dalból - Ogg hang + Ogg audio Megjelenítés Hangsáv Szerkeszt Lemez - Playlista létrehozva + Lejátszólista létrehozva Fekete téma Lekerekített sarkok engedélyezése további UI elemeken (az albumborítók lekerekítése szükséges) Könyvtár fülek Mód Free Lossless Audio Codec (FLAC) Beállítás címkékkel - A könyvtárból történő lejátszáskor - Lejátszás a megjelenő elemről + A könyvtárból történő lejátszáskor + Lejátszás a megjelenő elemről %d kbps Betöltött műfaj: %d Zene betöltés @@ -221,7 +219,7 @@ Zene könyvtár figyelése Állapot mentve Lejátszás megállítása - Egyszerű, racionális zene lejátszó androidra. + Egyszerű, praktikus zenelejátszó androidra. Matroska hang Album Kislemezek @@ -245,18 +243,18 @@ %d előadó %d előadó - Equalizer - Könyvtári statisztika + Ekvalizer + Könyvtár statisztika Playlista átnevezve Playlista törölve Ugrás a következőre - Lejátszás előadótól + Lejátszás előadótól Zene A nem zenei fájlok, például podcastok figyelmen kívül hagyása Több címkeértéket jelölő karakterek konfigurálása Vessző (,) - Ponosvessző (;) - És (&) + Pontosvessző (;) + És jel (&) Intelligens rendezés Közreműködők elrejtése Csak az albumon közvetlenül feltüntetett előadók megjelenítése (a jól címkézett könyvtárakban működik a legjobban) @@ -294,4 +292,17 @@ Auxio ikon Nincs lemez %s szerkesztése + Négyzet alakú albumborítók + Az összes albumborító 1:1 arányra vágása + Dal + Megnéz + Dal lejátszása önmagában + Irány + Rendezés + Kiválasztás + Kép kiválasztás + További + Másolva + Jelentés + Hiba információ \ 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 4e9be7cdf..3b2486eb4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -20,8 +20,8 @@ Putar berikutnya Tambahkan ke antrean Ditambahkan ke antrean - Buka artis - Buka album + Buka artis + Buka album Tentang Versi Kode sumber @@ -93,8 +93,8 @@ Pre-amp diterapkan ke penyesuaian yang ada selama pemutaran Penyesuaian dengan tag Peringatan: Mengubah pre-amp ke nilai positif yang tinggi dapat mengakibatkan puncak pada beberapa trek audio. - Putar dari item yang ditampilkan - Putar dari semua lagu + Putar dari item yang ditampilkan + Putar dari semua lagu Tetap mengacak saat memutar lagu baru Jeda pada pengulangan Putar balik sebelum melompat ke belakang @@ -133,17 +133,17 @@ Album yang dimuat: %d Artis yang dimuat: %d Utamakan album jika ada yang diputar - Saat diputar dari pustaka - Putar dari album + Saat diputar dari pustaka + Putar dari album Ubah mode pengulangan Gambar Artis untuk %s - Saat diputar dari keterangan item + Saat diputar dari keterangan item Musik tidak akan dimuat dari folder yang Anda tambahkan. Hapus lagu antrian ini Hapus kueri pencarian Penyesuaian tanpa tag Folder musik - Putar dari artis + Putar dari artis Mode Auxio memerlukan izin untuk membaca perpustakaan musik Anda Loncat ke lagu terakhir @@ -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 (,) @@ -193,7 +191,7 @@ Kualitas tinggi Titik koma (;) Wiki - Putar dari aliran + Putar dari aliran Aliran Sampul album Nonaktif @@ -213,4 +211,26 @@ Pustaka Pemutaran Ampersand (&) + Kompilasi + Kompilasi remix + EP + EP Live + Kompilasi + Kaset campuran + Lainnya + Soundtrack + Album live + + %d artis + + Single Live + EP + Kaset campuran + Single remix + Lagu + Single + Soundtrek + Kompilasi live + EP Remix + Single \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fb003c4c8..1a4bf8938 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,16 +1,16 @@ - Un semplice, razionale lettore musicale per android. + Semplice e razionale lettore musicale per android. Vedi e gestisci la riproduzione musicale Riprova - Permetti + Autorizza Generi Artisti Album - Canzoni - Tutte le canzoni + Brani + Tutte i brani Cerca Filtro Tutto @@ -23,15 +23,15 @@ Ora in riproduzione Riproduci Mescola - Riproduci da tutte le canzoni - Riproduci dall\'album - Riproduci dall\'artista + Riproduci da tutti i brani + Riproduci dall\'album + Riproduci dall\'artista Coda Riproduci successivo Accoda Accodato - Vai all\'artista - Vai all\'album + Vai all\'artista + Vai all\'album Stato salvato Aggiungi Salva @@ -66,13 +66,13 @@ Preferisci album Preferisci l\'album se in riproduzione Comportamento - Quando in riproduzione dalla libreria + Quando in riproduzione dalla libreria Mantieni mescolamento - Mantiene il mescolamento anche se una nuova canzone è selezionata + Mantiene il mescolamento anche se un nuovo brano è selezionato Riavvolgi prima di saltare indietro - Riavvolge prima di saltare alla traccia precedente + Riavvolge prima di saltare al brano precedente Pausa alla ripetizione - Pausa quando una canzone si ripete + Pausa quando un brano si ripete Contenuti Salva stato riproduzione Salva lo stato di riproduzione corrente @@ -89,13 +89,13 @@ Canzone %d Riproduci o pausa - Salta alla canzone successiva - Salta alla canzone precedente + Salta a brano successivo + Salta a ultimo brano Cambia modalità ripetizione Attiva o disattiva mescolamento - Mescola tutte le canzoni - Rimuove questa canzone della coda - Muove questa canzone della coda + Mescola tutti i brani + Rimuovi questo brano + Sposta questo brano Muove questa scheda Cancella la query di ricerca Rimuovi cartella @@ -128,15 +128,15 @@ Marrone Grigio - Canzoni trovate: %d + Brani trovati: %d Album trovati: %d Artisti trovati: %d Generi trovati: %d Durata totale: %s - %d canzone - %d canzoni - %d canzoni + %d brano + %d brani + %d brani %d album @@ -154,14 +154,14 @@ +%.1f dB %d Hz Caricamento libreria musicale… (%1$d/%2$d) - Quando in riproduzione dai dettagli dell\'elemento + Quando in riproduzione dai dettagli dell\'elemento Attenzione: impostare valore positivi alti può provocare distorsioni su alcune tracce. Regolazione senza tag Mescola Mescola tutto Regolazione con tag Il pre-amp è applicato alla regolazione esistente durante la riproduzione - Riproduci dall\'elemento mostrato + Riproduci dall\'elemento mostrato Gestisci le cartelle da dove caricare la musica Cartelle musica Escludi @@ -174,13 +174,13 @@ Caricamento musica Caricamento libreria musicale… Durata - Numero canzoni + Numero brani Disco Traccia OK Frequenza di campionamento Vedi proprietà - Proprietà canzone + Proprietà brano Nome file Directory superiore Formato @@ -231,7 +231,7 @@ Copertine album Off Veloce - Escludi non-musica + Escludi file non musicali Ignora file audio non musicali, come i podcast Separatori multi-valore Configura caratteri che indicano valori multipli di tag @@ -239,7 +239,7 @@ Attenzione: potrebbero verificarsi degli errori nella interpretazione di alcuni tag con valori multipli. Puoi risolvere aggiungendo come prefisso la barra rovesciata (\\) ai separatori indesiderati. E commerciale (&) Raccolte live - Raccolta di remix + Raccolta remix Mix DJ Mix DJ Alta qualità @@ -255,10 +255,8 @@ 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 + Riproduci dal genere Wiki %1$s, %2$s Ripristina @@ -274,7 +272,7 @@ Persistenza Personalizza controlli e comportamento dell\'UI Configura comportamento di suono e riproduzione - Discendente + Discendente Playlist Playlist Ordinazione intelligente @@ -284,8 +282,8 @@ Nuova playlist Aggiungi a playlist Playlist creata - Aggiunto alla playlist - Niente canzoni + Aggiunto a playlist + Nessun brano Playlist %d Elimina Eliminare la playlist\? @@ -299,4 +297,10 @@ Nessun disco Appare su Modifica di %s + Forza copertine album quadrate + Adatta tutte le copertine degli album ad una visualizzazione 1:1 + 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 1e1db4996..d74cc9319 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 חי רמיקסים אומן אומנים - סוגה - סוגות + ז\'אנר + ז\'אנרים סינון הכל תאריך @@ -36,19 +36,17 @@ תאריך הוספה מיון עולה - יורד + יורד מושמע כעת איקוולייזר ניגון - ניגון הנבחרים ערבוב - ערבוב הנבחרים ניגון הבא הוספה לתור - מעבר לאלבום + מעבר לאלבום הצגת מאפיינים מאפייני שיר - תבנית + פורמט גודל קצב סיביות קצב דגימה @@ -62,11 +60,11 @@ גרסה קוד מקור ויקי - רישיונות + רשיונות סטטיסטיקות ספרייה צפייה ושליטה בהשמעת המוזיקה - טוען את ספריית המוזיקה שלך… - סורק את ספריית המוזיקה שלך כדי לאתר שינויים… + ספריית המוזיקה שלך נטענת… + ספריית המוזיקה שלך נסרקת לאיתור שינויים… התווסף לתור מפותח על ידי אלכסנדר קייפהארט חיפוש בספרייה שלך… @@ -80,32 +78,32 @@ שימוש בערכת נושא שחורה לגמרי מצב מעוגל התאמה אישית - התאמת רכיבים והתנהגות ממשק המשתמש + התאמת רכיבי והתנהגות הממשק תצוגה לשוניות ספרייה פעולת התראות מותאמת אישית דילוג לבא מצב חזרה התנהגות - כאשר מנוגן מהספרייה - כאשר מנוגן מפרטי הפריט - ניגון מהפריט המוצג - ניגון מכל השירים - ניגון מאלבום - ניגון מהאומן - ניגון מסוגה - לזכור ערבוב + כאשר מנוגן מהספרייה + כאשר מנוגן מפרטי הפריט + ניגון מהפריט המוצג + ניגון מכל השירים + ניגון מאלבום + ניגון מהאומן + ניגון מז\'אנר + זכירת ערבוב המשך ערבוב בעת הפעלת שיר חדש תוכן טעינה מחדש אוטומטית - לטעון מחדש את הספרייה בכל פעם שהיא משתנה (דורש התראה קבועה) - התעלמות מקובצי שמע שאינם מוזיקה, כמו הסכתים + טעינת הספרייה מחדש בכל פעם שהיא משתנה (דורש התראה קבועה) + התעלמות מקבצי אודיו שאינם מוזיקה, כמו הסכתים מפרידים רבי-ערכים פסיק (,) נקודה-פסיק (;) פלוס (+) גם (&) - הסתרת שיתופי פעולה + הסתרת משתפי~ות פעולה הצגת אומנים שמצויינים ישירות בקרדיטים של אלבום בלבד (עובד באופן מיטבי על ספריות מתויגות היטב) עטיפות אלבום כבוי @@ -118,7 +116,7 @@ עצירה בעת חזרה ReplayGain העדפת אלבום - מגבר עוצמת נגינה מחדש + מגבר ReplayGain התאמה עם תגיות מיקסטייפ נגן מוזיקה פשוט והגיוני לאנדרואיד. @@ -132,28 +130,28 @@ שם רצועה תור - מעבר לאומן + מעבר לאומן שם קובץ ערבוב המצב שוחזר - על אודות + אודות הגדרות אוטומטי הפעלת פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות) שינוי מראה וסדר לשוניות הספרייה פעולת סרגל השמעה מותאמת אישית - הגדרת טעינת המוזיקה והתמונות + הגדרת אופן טעינת מוזיקה ותמונות מוזיקה אי-הכללת תוכן שאינו מוזיקה התאמת תווים המציינים ערכי תגית מרובים קו נטוי (/) אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים. איכות גבוהה - התעלמות ממילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית) + התעלמות ממספרים או מילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית) תמונות הגדרת הצליל והניגון תמיד להתחיל לנגן ברגע שמחוברות אזניות (עלול לא לעבוד בכל המערכות) - השהיה עם חזרה על שיר + השהייה עם חזרה על שיר העדפת רצועה אסטרטגיית ReplayGain העדפת אלבום אם אחד מופעל @@ -162,7 +160,7 @@ רשימת השמעה חדשה הוספה לרשימת השמעה לתת - רשימת השמעה + רשימת השמעה (פלייליסט) רשימות השמעה מחיקה שינוי שם @@ -172,20 +170,20 @@ לא ניתן לנקות את המצב כתום תיקיות מוזיקה - טעינה מחדש של ספריית המוזיקה, במידה וניתן יעשה שימוש במטמון תגיות - סריקה מחדש אחר מוזיקה + טעינה מחדש של ספריית המוזיקה, במידה וניתן ייעשה שימוש בתגיות מהמטמון + סריקת מוסיקה מחדש שמירת מצב הנגינה לא ניתן לשמור את המצב ‏ Auxio צריך הרשאות על מנת לקרוא את ספריית המוזיקה שלך פתיחת התור - סך הכל משך: %s + משך כולל: %s רשימת השמעה %d אומנים טעונים: %d שירים טעונים: %d אלבומים טעונים: %d - סוגות טעונות: %d + ז\'אנרים טעונים: %d המצב נוקה - ספרייה + ספריה שמירת מצב הנגינה הנוכחי כעת לא נמצא יישום שיכול לטפל במשימה זו אין תיקיות @@ -199,17 +197,17 @@ תמונת אומן עבור %s יצירת תמונה עבור %s אומן לא ידוע - סוגה לא ידועה + ז\'אנר לא ידוע אין תאריך אין רצועה - אך מוזיקה אינה מתנגנת + מוזיקה לא מתנגנת כחול כחול עמוק אפור דינמי - המוזיקה שלך בטעינה (‎%1$d/%2$d)… + המוזיקה שלך בטעינה… (‎%1$d/%2$d) דיסק %d - ניהול תיקיות המוזיקה לטעינה + ניהול המקומות שמהם תיטען מוזיקה אין שירים ורוד נוצרה רשימת השמעה @@ -217,7 +215,7 @@ אומן אחד שני אומנים - %d אומנים + %d אומנים לכלול רענון מוזיקה @@ -228,14 +226,14 @@ שיר אחד שני שירים - %d שירים + %d שירים אלבום אחד שני אלבומים - %d אלבומים + %d אלבומים - שונה שם לרשימת השמעה + שונה שם רשימת ההשמעה רשימת השמעה נמחקה נוסף לרשימת השמעה ערבוב כל השירים @@ -244,7 +242,7 @@ תמונת רשימת השמעה עבור %s אדום ירוק - ניתוב הורה + נתיב ראשי לא ניתן לשחזר את המצב רצועה %d יצירת רשימת השמעה חדשה @@ -259,5 +257,48 @@ אין דיסק ירוק עמוק צהוב - מחיקת %s\? פעולה זו לא ניתן לביטול. + למחוק את %s\? פעולה זו לא ניתן לביטול. + שיר + מיון חכם + הצגה + הכרחת עטיפות אלבום מרובעות + ריקון מטמון התגיות וטעינת ספריית המוזיקה מחדש במלואה (איטי יותר, אך יותר שלם) + ניקוי מצב הנגינה הקודם שנשמר (אם קיים) + מיון על פי + כיוון + חיתוך כל עטיפות האלבומים ליחס של 1:1 + מוזיקה לא תיטען מהתיקיות שנוספו. + מוזיקה תיטען רק מהתיקיות שנוספו. + מופיע~ה ב- + ניגון השיר בלבד + אזהרה: שינוי המגבר לערך חיובי גבוה עלול לגרום לעיוות (דיסטורשן) בחלק מרצועות האודיו. + שחזור מצב נגינה + אינדיגו + אודיו MPEG-1 + אודיו MPEG-4 + אודיו Ogg + ציאן + טורקיז + חום + %d נבחרו + התמדה + עוד + בחירה + מידע על השגיאה + דיווח + תמונה נבחרת + קודק אודיו חופשי ללא איבוד נתונים (FLAC) + סגול + סגול עמוק + +%.1f דציבלים (dB) + -%.1f דציבלים (dB) + %d הרץ (Hz) + %d קילוביטים לשנייה (kbps) + מועתק + שחזור מצב הנגינה שנשמר קודם (אם קיים) + אודיו Matroska + קידוד אודיו מתקדם (AAC) + %1$s, %2$s + ליים + %s נערך \ 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 8117c1f3a..7c011f04e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -54,16 +54,15 @@ カラースキーム 黒基調 再生状態を復元 - 表示されたアイテムから再生 + 表示されたアイテムから再生 再生停止 ファイル名 追加した日付け サンプルレート - 降順 + 降順 再生 シャフル - 選択曲をシャフル 次に再生 再生待ちに追加 オーディオ形式 @@ -116,8 +115,8 @@ %d 人のアーティスト - アーティストに移動 - アルバムに移動 + アーティストに移動 + アルバムに移動 曲のプロパティ 再生待ちに追加 開発者 アレクサンダー・ケイプハート (Alexander Capehart) @@ -151,11 +150,11 @@ ヘッドセット接続時に常時再生開始 (動作しない機種あり) ヘッドセット自動再生 リプレイゲイン - すべての曲から再生 - アルバムから再生 - アーティストから再生 - ライブラリからの再生時 - アイテム詳細からの再生時 + すべての曲から再生 + アルバムから再生 + アーティストから再生 + ライブラリからの再生時 + アイテム詳細からの再生時 音楽以外を除外 ここに追加したフォルダからのみ音楽が読み込まれます。 前回保存された再生状態がある場合、再生状態を復元 @@ -178,7 +177,6 @@ リミックスEP リミックス ジャンル - 選択曲を再生 プロパティを見る 再生待ち ライブラリ統計 @@ -227,7 +225,7 @@ 次ヘスキップ 繰り返しモード - ジャンルから再生 + ジャンルから再生 新しい曲の再生時にシャフルを保持 ダイナミック 再生状態を解除できません @@ -284,4 +282,7 @@ ディスクがありません %sを編集中 に登場します + + フォーススクエアのアルバムカバー + すべてのアルバム カバーを 1:1 のアスペクト比にトリミングします \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 51108beaa..0d9822a7f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,11 +1,11 @@ - 단순하고, 실용적인 안드로이드용 뮤직 플레이어입니다. + 단순하고, 실용적인 안드로이드용 음악 플레이어입니다. 음악 재생 제어 및 상태 확인 - 재시도 - 허가 + 다시 시도 + 허용 장르 아티스트 앨범 @@ -26,16 +26,16 @@ 오름차순 지금 재생 중 재생 - 셔플 - 모든 곡에서 재생 - 앨범에서 재생 - 아티스트에서 재생 + 무작위 재생 + 모든 곡에서 재생 + 앨범에서 재생 + 아티스트에서 재생 대기열 다음 곡 재생 대기열에 추가 - 대기열에 추가됨 - 아티스트로 이동 - 앨범으로 이동 + 대기열에 추가했습니다. + 아티스트로 이동 + 앨범으로 이동 상태 저장됨 확인 @@ -46,24 +46,24 @@ 정보 버전 소스 코드 - 라이센스 + 라이선스 Alexander Capehart가 개발 라이브러리 통계 설정 - 보고 느낌 + 모양과 느낌 테마 자동 - 밝음 - 어두움 + 라이트 테마 + 다크 테마 배색 검정 테마 - 어두운 테마에 검정색 사용 + 다크 테마에 검정색 사용 화면 라이브러리 탭 - 라이브러리 탭의 순서 및 표시할 탭 변경 + 라이브러리 탭 순서 및 표시할 탭 변경 둥근 UI 모드 - 기타 UI 요소 가장자리를 둥글게 표시 (앨범 커버도 둥글어짐) + 앨범 커버를 포함한 기타 UI의 가장자리를 둥글게 표시합니다. 알림 동작 사용자 정의 소리 헤드셋 자동 재생 @@ -71,32 +71,32 @@ ReplayGain 계획 트랙 선호 앨범 선호 - 앨법 재생 중인 경우 앨범 선호 + 앨범 재생 중인 경우 앨범 선호 ReplayGain 프리앰프 재생 중에 프리앰프를 적용하여 조정 태그로 조정 태그 없이 조정 - 주의: 프리앰프를 높게 설정하면 일부 소리 트랙이 왜곡될 수 있습니다. - 동작 - 라이브러리에서 재생할 때 + 주의: 프리앰프를 높게 설정하면 일부 오디오 트랙이 왜곡될 수 있습니다. + 개인화 + 라이브러리에서 재생할 때 무작위 재생 기억 - 새로운 곡을 재생할 때 무작위 재생 유지 + 새로운 곡을 재생할 때 무작위 재생 모드 유지 이전 곡으로 가기 전에 되감기 이전 곡으로 건너뛰기 전에 먼저 현재 트랙을 되감기 반복 재생 시 일시 중지 곡이 반복 재생될 때 일시 중지 내용 재생 상태 저장 - 현재 재생 상태를 즉시 저장 + 현재 재생 상태를 지금 저장합니다. 음악 새로고침 - 이미 저장된 태그를 가능한 활용하여 음악 라이브러리를 다시 만들기 + 캐시된 태그를 사용하여 음악 라이브러리를 다시 불러옵니다. 음악 없음 음악 불러오기 실패 - Auxio가 음악 라이브러리를 읽을 수 있는 권한이 필요함 - 이 작업을 처리할 수 있는 앱을 찾지 못함 + 앱에서 음악 라이브러리를 읽을 수 있는 권한이 필요합니다. + 이 작업을 처리할 수 있는 앱을 찾을 수 없습니다. 폴더 없음 - 이 폴더는 지원되지 않음 + 지원하지 않는 폴더입니다. 라이브러리에서 검색… @@ -107,8 +107,8 @@ 반복 방식 변경 무작위 재생 켜기 또는 끄기 모든 곡 무작위 재생 - 이 대기열의 곡 제거 - 이 대기열의 곡 이동 + 이 곡 제거 + 이 곡 이동 이 탭 이동 검색 기록 삭제 폴더 제거 @@ -157,8 +157,8 @@ %d 앨범 MPEG-4 오디오 - 자유 무손실 오디오 코덱 (FLAC) - 이전에 저장된 재생 상태 지우기 (있는 경우) + Free Lossless Audio Codec (FLAC) + 이전에 저장된 재생 상태 초기화 제외 추가한 폴더에서만 음악을 불러옵니다. 곡 속성 @@ -167,34 +167,34 @@ 샘플 속도 전송 속도 크기 - 모두 셔플 + 모두 무작위 재생 재생 중지 Ogg 오디오 - 마트로스카 오디오 + Matroska 오디오 %d Hz DJ믹스 라이브 컴필레이션 리믹스 편집 DJ믹스 이퀄라이저 - 셔플 - 표시된 항목에서 재생 - 음악 라이브러리를 불러오는 중… + 무작위 재생 + 표시된 항목에서 재생 + 음악 라이브러리 불러오는 중… 재생 상태 지우기 재생 상태 복원 음악 폴더 음악을 불러오는 위치 관리 추가한 폴더에서 음악을 불러오지 않습니다. 포함 - 다중값 구분자 - 여러 태그 값을 나타낼때의 구분자 설정 + 다중 값 구분 기호 + 태그 값이 여러 개일 때 태그를 구분할 기호를 설정합니다. 콤마 (,) 세미콜론 (;) - 슬래쉬 (/) + 슬래시 (/) 플러스 (+) 앰퍼샌드 (&) MPEG-1 오디오 - 추가된 날짜 + 추가한 날짜 상위 경로 맞춤형 재생 동작 버튼 반복 방식 @@ -208,20 +208,20 @@ 컴필레이션 라이브 형식 - 음악 아닌 것 제외 + 음악이 아닌 항목 제외 %d kbps - 고급 오디오 코딩 (AAC) + Advanced Audio Coding (AAC) 앨범 커버 빠름 고품질 - 이전에 저장된 재생 상태 복원 (있는 경우) - 재생상태를 복원할 수 없음 + 이전에 저장된 재생 상태 복원 + 재생 상태를 복원할 수 없습니다. 음악 라이브러리가 변경될 때마다 새로고침 (고정 알림 필요) 상태 지워짐 - 음악 불러오기 중 - 음악 불러오기 중 - 음악 라이브러리 추적중 + 음악 불러오는 중 + 음악 불러오는 중 + 음악 라이브러리 모니터링 중 상태 복원됨 EP 앨범 EP 앨범 @@ -234,65 +234,76 @@ 믹스테이프 리믹스 자동 새로고침 - 처리방식 + 모드 음악 라이브러리를 불러오는 중… (%1$d/%2$d) 장르 - 경고: 이 설정을 사용하면 일부 태그가 여러 값을 갖는 것으로 잘못 해석될 수 있습니다. 구분자로 읽히지 않도록 하려면 해당 구분자 앞에 백슬래시 (\\)를 붙입니다. - 항목 세부 정보에서 재생할 때 - 음악 라이브러리의 변경사항을 추적하는 중… + 경고: 이 설정을 사용하면 몇몇 태그가 다중 값을 가진 것으로 잘못 나타날 수 있습니다. 태그에서 구분 기호 앞에 백슬래시(\\)를 붙이면 구분 기호로 인식하지 않습니다. + 항목 세부 정보에서 재생할 때 + 음악 라이브러리 변경 사항 모니터링 중… 다음 곡으로 건너뛰기 - 팟캐스트와 같이 음악이 아닌 소리 파일 무시 - 공동작업자 숨기기 + 팟캐스트 등 음악이 아닌 오디오 파일 무시 + 공동 작업자 숨기기 앨범에 등장하는 아티스트만 표시 (자세히 태그된 라이브러리에 최적화) - 재생상태를 지울 수 없음 - 재생상태를 저장할 수 없음 + 재생 상태를 지울 수 없습니다. + 재생 상태를 저장할 수 없습니다. 음악 재탐색 %d 아티스트 - 태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함) - 선택한 재생 - 선택한 셔플 + 태그 캐시를 지우고 음악 라이브러리를 처음부터 다시 생성합니다. 느리지만 더 완벽한 방식입니다. %d 선택됨 재설정 위키 - 장르에서 재생 + 장르에서 재생 %1$s, %2$s - 리플레이게인 + ReplayGain 사운드 및 재생 동작 구성 재생 폴더 - 앱의 테마 및 색상 변경 + 앱 테마 및 색상 변경 음악 라이브러리 이미지 - 음악 및 이미지 불러오기 방법 제어 + 음악 및 이미지 불러오기 방식 설정 지속 동작 - UI 제어 및 동작 커스텀 - 내림차순 - 재생목록 - 재생목록 + UI 제어 및 동작 사용자 정의 + 내림차순 + 재생 목록 + 재생 목록 %s의 재생 목록 이미지 - 정렬할 때 기사 무시 - 이름으로 정렬할 때 \"the\"와 같은 단어 무시(영어 음악에서 가장 잘 작동함) + 적응형 정렬 + 정렬할 때 숫자나 \"the\"와 같은 단어를 무시합니다. 태그가 영어로 되어 있을 때 가장 잘 작동합니다. 새 재생 목록 만들기 - 새 재생목록 - 재생목록에 추가 - 생성된 재생목록 - 재생목록에 추가됨 - 재생목록 %d + 새 재생 목록 + 재생 목록에 추가 + 재생 목록을 만들었습니다. + 재생 목록에 추가했습니다. + 재생 목록 %d 노래 없음 삭제 - %s를 삭제하시겠습니까\? 이 취소 할 수 없습니다. + %s 항목을 삭제하시겠습니까\? 이 작업은 취소할 수 없습니다. 이름 바꾸기 - 재생목록 이름 바꾸기 - 재생목록을 삭제하시겠습니까\? - 편집하다 + 재생 목록 이름 바꾸기 + 재생 목록을 삭제하시겠습니까\? + 편집 에 나타납니다 - 공유하다 - 재생목록의 이름이 변경됨 + 공유 + 재생 목록의 이름을 바꿨습니다. 디스크 없음 - 재생목록이 삭제되었습니다 - %s 수정 중 + 재생 목록을 삭제했습니다. + %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 6f9d600e2..94c41de55 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -2,22 +2,22 @@ Dainos Visos dainos - Ieškoti + Paieška Filtruoti Visos - Rūšiuoti + Rūšiavimas Pavadinimas Metai Trukmė - Dainų skaičius + Dainos skaičius Diskas Pridėta data - Kylantis + Didėjantis Groti kitą Pridėti į eilę Eilė - Eiti į atlikėją - Eiti į albumą + Eiti į atlikėją + Eiti į albumą Peržiūrėti ypatybes Dydis Bitų srautas @@ -34,7 +34,7 @@ Groti Licencijos Maišyti - Pridėta į eilę + Pridėtas į eilę Dainų ypatybės Failo pavadinimas Išsaugoti @@ -44,22 +44,22 @@ Formatas Versija Nustatymai - Temos - Naudokti grynai juodą tamsią temą - Paprastas, racionalus „Android“ muzikos grotuvas. - Muzika kraunama - Peržiūrėti ir valdyti muzikos grojimą + Tema + Naudoti grynai juodą tamsią temą + Paprastas, racionalus Android muzikos grotuvas. + Muzikos pakraunimas + Peržiūrėk ir valdyk muzikos grojimą Žanrai - Bandykite dar kartą + Pakartoti Suteikti Kraunama muzika - Kraunama jūsų muzikos biblioteka… + Kraunamas tavo muzikos biblioteka… Bibliotekos statistika Rožinis Albumas Mini albumas Singlas - Atlikėjas + Atlikėjas (-a) Nežinomas žanras Nėra datos Raudona @@ -71,22 +71,22 @@ Nežinomas atlikėjas Albumo viršelis Giliai violetinė - Stebėjima muzikos biblioteka - Stebima jūsų muzikos biblioteka dėl pakeitimų… + Stebėjimas muzikos biblioteka + Stebimas tavo muzikos biblioteka dėl pakeitimų… Maišyti - Išmaišyti viską - Būsena atkurta + Maišyti viską + Atkurta būsena Išsaugota būsena Atšaukti Šaltinio kodas - Rodyti - „ReplayGain“ strategija + Rodinys + ReplayGain strategija Singlai Gerai - Įgalinti papildomų vartotojo sąsajos elementų suapvalintus kampus (reikia, kad albumo viršeliai būtų suapvalinti) + Įjungti suapvalintų kampų papildomiems UI elementams (reikia, kad albumo viršeliai būtų suapvalinti) Garso takelis Garso takeliai - Garso + Garsas Apvalus režimas Pasirinktinis pranešimo veiksmas MPEG-1 garsas @@ -102,8 +102,8 @@ Remiksai Arbatžolė Geltona - Išplėstinis Garso Kodavimas (AAC) - Nemokamas Be Nuostolių Garso Kodekas (FLAC) + Išplėstinis garso kodavimas (AAC) + Nemokamas be nuostolių garso kodekas (FLAC) %d daina %d dainos @@ -121,27 +121,27 @@ Gyvai albumas Remikso albumas Gyvai - Visada pradėti groti, kai prijungtos ausinės (gali veikti ne visuose įrenginiuose) + Visada pradėti groti, kai ausinės yra prijungtos (gali neveikti visuose įrenginiuose) Ogg garsas - Sukūrė Alexanderis Capehartas - Pageidaujamas takeliui + Sukūrė Alexanderis Capehartas (angl. Alexander Capehart) + Pageidauti takelį Jokių aplankų Šis aplankas nepalaikomas Groti arba pristabdyti - Peršokti į kitą dainą - Peršokti į paskutinę dainą + Praleisti į kitą dainą + Praleisti į paskutinę dainą Mikstapas Mikstapai Bibliotekos skirtukai Keisti bibliotekos skirtukų matomumą ir tvarką - Pageidaujamas albumui - Pageidaujamas albumui, jei vienas groja + Pageidauti albumui + Pageidauti albumui, jei vienas groja Jokią programą nerasta, kuri galėtų atlikti šią užduotį Auxio piktograma Perkelti šią dainą Perkelti šį skirtuką - Muzikos krovimas nepavyko - Auxio reikia leidimo skaityti jūsų muzikos biblioteką + Muzikos įkrovimas nepavyko + Auxio reikia leidimo skaityti tavo muzikos biblioteką Diskas %d +%.1f dB -%.1f dB @@ -152,69 +152,69 @@ Kompiliacija Prisiminti maišymą Palikti maišymą įjungtą, kai groja nauja daina - Persukti prieš šokant atgal - Persukti atgal prieš peršokant į ankstesnę dainą + Persukti prieš praleistant atgal + Persukti atgal prieš praleistant į ankstesnę dainą Pauzė ant kartojamo - Kai grojant iš bibliotekos - Kai grojant iš elemento detalių + Kai grojant iš bibliotekos + Kai grojant iš elemento detalių Pašalinti aplanką Žanras - Ieškokite savo bibliotekoje… + Ieškoti savo bibliotekoje… Ekvalaizeris Režimas - Automatinis krovimas + Automatinis įkrovimas Jokios muzikos nerasta Sustabdyti grojimą Nėra takelio - Pereiti prie kitos + Praleisti į kitą Automatinis ausinių grojimas Kartojimo režimas Atidaryti eilę - Išvalyti paieškos užklausą - Muzika nebus įkeliama iš pridėtų aplankų jūs pridėsite. + Išvalyti paieškos paraišką + Muzika nebus kraunama iš pridėtų aplankų, kurių tu pridėsi. Įtraukti Pašalinti šią dainą - Groti iš visų dainų - Groti iš parodyto elemento - Groti iš albumo - Groti iš atlikėjo + Groti iš visų dainų + Groti iš parodyto elemento + Groti iš albumo + Groti iš atlikėjo Išvalyta būsena Neįtraukti - Muzika bus įkeliama iš aplankų jūs pridėsite. + Muzika bus kraunama iš aplankų, kurių tu pridėsi. %d Hz Perkrauti muzikos biblioteką, kai ji pasikeičia (reikia nuolatinio pranešimo) - Įkeltos dainos: %d - Įkrauti žanrai: %d - Įkeltos albumai: %d - Įkrauti atlikėjai: %d - Kraunama jūsų muzikos biblioteka… (%1$d/%2$d) + Pakrautos dainos: %d + Pakrautos žanros: %d + Pakrauti albumai: %d + Pakrauti atlikėjai: %d + Kraunama tavo muzikos biblioteka… (%1$d/%2$d) Maišyti visas dainas - Elgesys - Įspėjimas: Keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti pikų. - Albumo viršelis, skirtas %s - Atlikėjo vaizdas, skirtas %s - Nėra grojamos muzikos - Pauzė, kai daina kartojasi + Personalizuotas + Įspėjimas: keičiant išankstinį stiprintuvą į didelę teigiamą vertę, kai kuriuose garso takeliuose gali atsirasti tarpų. + Albumo viršelis %s + Atlikėjo vaizdas %s + Nėra grojančio muzikos + Sustabdyti, kai daina kartojasi Turinys Muzikos aplankai Atnaujinti muziką Perkrauti muzikos biblioteką, naudojant talpyklos žymes, kai įmanoma Pasirinktinis grojimo juostos veiksmas Nepavyko atkurti būsenos - „ReplayGain“ išankstinis stiprintuvas + ReplayGain išankstinis stiprintuvas Išsaugoti grojimo būseną - Tvarkykite, kur muzika turėtų būti įkeliama iš - Žanro vaizdas, skirtas %s + Tvarkyti, kur muzika turėtų būti įkeliama iš + Žanro vaizdas %s Įjungti maišymą arba išjungti Takelis %d Keisti kartojimo režimą Indigos %d kbps - DJ\'ų Miksai - DJ\'o Miksas + DJ miksai + DJ miksas Gyvai kompiliacija Remikso kompiliacija - Pagrindinis aplankas + Pirminis kelias Išvalyti anksčiau išsaugotą grojimo būseną (jei yra) Daugiareikšmiai separatoriai Pasvirasis brūkšnys (/) @@ -222,18 +222,18 @@ Ampersandas (&) Albumų viršeliai Išjungta - Greitai + Greitis Išsaugoti dabartinę grojimo būseną dabar Išvalyti grojimo būseną - Konfigūruokite simbolius, kurie nurodomos kelias žymių reikšmes + Konfigūruoti simbolius, kurie nurodo kelias žymių reikšmes Kablelis (,) Reguliavimas be žymų - Įspėjimas: Naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus naudojant atgalinį brūkšnį (\\). + Įspėjimas: naudojant šį nustatymą, kai kurios žymos gali būti neteisingai interpretuojamos kaip turinčios kelias reikšmes. Tai galima išspręsti prieš nepageidaujamus skiriamuosius ženklus su agalinių brūkšniu (\\). Kabliataškis (;) - Aukšta kokybė + Aukštos kokybės Atkurti grojimo būseną Neįtraukti nemuzikinių - Ignoruoti garso failus, kurie nėra muzika, pavyzdžiui, podcast\'us + Ignoruoti garso failus, kurie nėra muzika, pvz., tinklalaides Išankstinis stiprintuvas taikomas esamam reguliavimui grojimo metu Reguliavimas su žymėmis Atkurti anksčiau išsaugotą grojimo būseną (jei yra) @@ -242,44 +242,42 @@ Nepavyko išvalyti būsenos Nepavyko išsaugoti būsenos - %d atlikėjas + %d atlikėjas (-a) %d atlikėjai - %d albumų %d atlikėjų + %d atlikėjų Perskenuoti muziką Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) - %d Pasirinkta - Groti pasirinktą - Pasirinktas maišymas - Groti iš žanro + %d pasirinkta + Groti iš žanro Viki %1$s, %2$s Nustatyti iš naujo Biblioteka Elgesys - Pakeisti programos temą ir spalvas - Valdyti, kaip muzika ir vaizdai įkeliama - Konfigūruoti garso ir grojimo elgesį - Naudotojo UI ir elgsenos pritaikymas + Pakeisk programos temą ir spalvas + Valdyk, kaip muzika ir vaizdai įkeliami + Konfigūruok garso ir grojimo elgesį + Pritaikyk UI valdiklius ir elgseną Muzika Vaizdai Grojimas - „ReplayGain“ + ReplayGain Aplankalai - Atkaklumas - Mažėjantis - Ignoruoti tokius žodžius kaip „the“, kai rūšiuojama pagal pavadinimą (geriausiai veikia su anglų kalbos muzika) - Ignoruoti straipsnius rūšiuojant + Pastovumas + Mažėjantis + Teisingai surūšiuoti pavadinimus, kurie prasideda skaičiais arba žodžiais, tokiais kaip „the“ (geriausiai veikia su anglų kalbos muzika) + Išmanusis rūšiavimas Grojaraštis Grojaraščiai Grojaraščio vaizdas %s Sukurti naują grojaraštį Naujas grojaraštis - Įtraukti į grojaraštį - Įtraukta į grojaraštį + Pridėti į grojaraštį + Pridėta į grojaraštį Ištrinti - Ištrinti %s\? To negalima atšaukti. + Ištrinti %s\? To negalima atkurti. Pervadinti Pervadinti grojaraštį Ištrinti grojaraštį\? @@ -292,5 +290,21 @@ Ištrintas grojaraštis Nėra disko Redaguojama %s - Rodoma + Pasirodo + Daina + Peržiūrėti + Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento + Priversti kvadratinių albumų viršelius + Groti dainą pačią + Rūšiuoti pagal + Kryptis + Pasirinkimo vaizdas + Pasirinkimas + Klaidos informacija + Nukopijuota + Daugiau + Pranešti + Nėra albumų + Demo + Demos \ 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 fd2f7d19c..f1ae5be53 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -1,6 +1,5 @@ - തിരഞ്ഞെടുത്തു കളിക്കുക രക്ഷിക്കുക പെരുമാറ്റം ഉള്ളടക്കം @@ -87,13 +86,22 @@ %d പാട്ട് %d പാട്ടുകൾ - കലാകാരനിലേക്ക് പോകുക + കലാകാരനിലേക്ക് പോകുക സവിശേഷതകൾ കാണുക സ്ഥിതി സംരക്ഷിച്ചു - അവരോഹണം + അവരോഹണം സ്ഥിതി പുനഃസ്ഥാപിച്ചു വിക്കി സ്ഥിതി മായ്ച്ചു തത്സമയം തത്സമയ സമാഹാരം + ഗീതം + ഇല്ലാതാക്കുക + പേരുമാറ്റുക + തിരുത്തുക + സ്വയമേവ + വ്യക്തിപരമാക്കുക + കാണുക + പങ്കിടുക + ദൃശ്യമാകുന്നു \ No newline at end of file diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 3a0906840..c276bd0bd 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -1,2 +1,293 @@ - \ No newline at end of file + + Legg til i kø + Personaliser + Artister innlastet: %d + Legg til i spilleliste + Kildekode + Lisenser + Lys + Automatisk + ReplayGain + Gjenoppfrisk musikk + Justering uten etiketter + Flytt denne fanen + Tøm søk + Auxio-ikon + Åpne køen + Ingen dato + MPEG-1-lyd + Brun + Spor + EP-er + EP + Lydspor + Lydspor + Mikstaper + Mikstape + Remikser + Artist + Artister + Sjanger + Sjangere + Ny spilleliste + Søk + Kompilasjoner + Kompilasjon + Live-kompilasjon + Remiks-kompilasjon + Bidrar på + Alle + Navn + Varighet + Sporantall + + Spill neste + Bibliotek + Kunne ikke lagre tilstand + + %d artist + %d artister + + + %d spor + %d spor + + Filter + Spilleliste + Spillelister + Slett + Disk + Gå til artist + Gå til album + Vis + Del + Størrelse + Bitrate + Samplerate + Omstokking + Omstokk alt + Avbryt + Om + Vis og kontroller musikkavspilling + Tillagt i kø + Gjentagelsesmodus + Tilpass grensesnittskontroller og adferd + Utseende og adferd + Avrundede hjørner i ytterligere grensesnittselementer (krever at albumsomslag er avrundet) + Behold omstokking ved avspilling av et nytt spor + Husk omstokking + Skråstrek (/) + Plusstegn (+) + Skjul bidragsytere + Bilder + Albumsomslag + Rask + Høy kvalitet + Alltid start avspilling når hodetelefoner kobles til (trenger ikke å virke på alle enheter) + Sett opp lyd- og avspillingsadferd + Lyd + ReplayGain-strategi + Håndter hvor musikk lastes inn fra + Forforsterkning brukes for eksisterende justering under avspilling + Modus + Lagre nåværende avspillingstilstand nå + Lagre avspillingstilstand + Tøm etiketthurtiglager og last inn hele musikkbiblioteket igjen (tregere, men mer fullstendig) + Vedvarende + Spor %d + Albumsomslag + Skru omstokking på eller av + Fjern dette sporet + Flytt dette sporet + Ingen disk + Ingen spor + Avansert audio-koding (AAC) + Dynamisk + %d valgt + Laster inn musikkbiblioteket ditt … (%1$d/%2$d) + %d Hz + Slett %s for godt\? + Gjenopprett tidligere lagret avspillingstilstand (hvis noen) + MPEG-4-lyd + Spilleliste %d + Cyanblå + Spor innlastet: %d + Dato + Endre drakten og programfargene + Mapper + Tøm avspillingstilstand + Fjern tidligere lagret avspillingstilstand (hvis noen) + Gul + Intelligent sortering + Gi nytt navn + Gi spillelisten nytt navn + Slett spilleliste\? + OK + Tilstand lagret + Versjon + Wiki + Tilbakestill + Legg til + Tilstand fjernet + Tilstand gjenopprettet + Fargedrakt + Svart drakt + Ifør helsvart drakt + Drakt + Mørk + Musikk + Utelat ikke-musikk + Komma (,) + Semikolon (;) + Automatisk gjeninnlasting + Ignorer lydfiler som ikke er musikk, som f-eks. nettradioopptak + Multiverdi-inndelere + Sett opp tegn for inndeling av flere etikett-verdier + Kun vis artister som er kreditert direkte på album (fungerer best med godt etikettmerkede bibliotek) + Reskann musikk + Musikk vil kun innlastes fra mappene du legger til. + Last inn musikkbiblioteket igjen, ved bruk av hurtiglagrede etiketter når mulig + Ingen mapper + Kunne ikke gjenopprette tilstand + Kunne ikke fjerne tilstand + Album innlastet: %d + Biblioteksstatistikk + Av + Avrundede hjørner + ReplayGain-forforsterkning + Justering med etiketter + Adferd + Innhold + Musikkmapper + Gjeninnlast musikkbibliotek når det endrer seg (krever vedvarende merknad) + Kunne ikke laste inn musikk + Denne mappen støttes ikke + Hopp til neste spor + Hopp til siste spor + Omstokk alle spor + Fjern mappe + Ukjent sjanger + Sjangerbilde for %s + Ukjent artist + Sjangere innlastet: %d + Stopp avspilling + Opprett en ny spilleliste + Grønn + Mørkegrønn + Turkis + Rediger + Albumsomslag for %s + Lilla + Blå + Fritt tapsfritt lydkodek (FLAC) + Indigo + Total varighet: %s + DJ-mikser + DJ-miks + Live + Spilles nå + Omstokking + Stigende + Format + Vis egenskaper + Spor-egenskaper + Filnavn + Overnevnt sti + Pause ved gjentagelse + Rød + + %d album + %d album + + Synkende + Spor + Dato tillagt + Sorter + Utviklet av Alexander Capehart + Søk i biblioteket ditt … + Innstillinger + Spilleliste opprettet + Spillelistenavn endret + Holder øye med endringer i musikkbiblioteket ditt … + Spilleliste slettet + Lagt til i spilleliste + Hopp til neste + Egendefinert merknadshandling + Foretrekk spor + Pause når et spor gjentas + Spol tilbake før spor hoppes over + Spol tilbake før hopp til forrige spor + Advarsel: Endring av forforsterkning til høy positiv verdi kan resultere i forvrengning ved høyt lydtrykk på noen spor. + Avspilling + Disk %d + Skjerm + Biblioteksfaner + Endre synlighet og rekkefølgen på biblioteksfaner + Egendefinert avspillingsfelt-handling + Utelat + Inkluder + Musikk vil ikke innlastes fra mappene du legger til. + Ingen spor + Ingen musikk spilles + Oransje + Ved avspilling fra elementsdetaljer + Spill fra album + Spill fra sjanger + Foretrekk album + Gjenopprett avspillingstilstand + Ampersand (&) + Spill sporet for seg selv + Påtving kvadratiske albumsomslag + Korrekt sortering av navn som begynner med tall eller ord som «the» (fungerer best med engelskspråklig musikk) + Beskjær alle albumsomslag til 1:1-sideforhold + Spill av eller pause + Artistbilde for %s + Auxio trenger tilgang til å lese musikkbiblioteket ditt + Rosa + Grå + Redigerer %s + %1$s, %2$s + Limegrønn + -%.1f dB + En enkel, rasjonell musikkspiller for Android. + Prøv igjen + Alle spor + Album + Live-EP + Spill fra alle spor + Laster inn musikk … + Live-singel + Laster inn musikk … + Holder øye med musikkbiblioteket … + Innvilg + Singler + Spor + Album + Live-album + Remiks-album + Remiks-EP + Singel + Remiks-singel + Mørkelilla + +%.1f dB + Tonekontroll + Endre gjentagelsesmodus + Spill + Lagre + Laster inn musikkbiblioteket ditt … + Ved avspilling fra bibliotek + Spill fra artist + Fant ikke noe musikk + Matroska-lyd + Spill fra vist element + Ogg-lyd + Mørkeblå + Foretrekk album hvis det avspilles + Hodesett-autoavspilling + Spillelistebilde for %s + Kontroller hvordan musikk og bilder innlastes + Installer et program som kan utføre denne handlingen først + Advarsel: Kan forårsake feilaktig tolkning av etiketter som om de har flere verdier. Kan løses ved å innlede uønskede inndelertegn med omvendt skråstrek (\\). + %d kbps + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2c89cd98d..c0ecc5905 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -17,16 +17,16 @@ Oplopend Afspelen Shuffle - Speel van alle nummers - Speel af van album - Speel van artiest + Speel van alle nummers + Speel af van album + Speel van artiest Nu afspelen Wachtrij Afspelen als volgende Toevoegen aan wachtrij Toegevoegd aan de wachtrij - Ga naar artiest - Ga naar album + Ga naar artiest + Ga naar album Staat gered Toevoegen Opslaan @@ -48,7 +48,7 @@ Gebruikt een afternatief notification action Audio Gedrag - Bij het afspelen vanuit de bibliotheek + Bij het afspelen vanuit de bibliotheek Onthoud shuffle Houd shuffle aan bij het afspelen van een nieuw nummer Terugspoelen voordat je terugspoelt @@ -149,7 +149,7 @@ Aanpassing met tags Aanpassing zonder tags Er speelt geen muziek - Bij het afspelen van item details + Bij het afspelen van item details Ronde modus Afgeronde hoeken inschakelen voor extra UI-elementen (vereist dat albumhoezen zijn afgerond) Staat gerestaureerd @@ -158,7 +158,7 @@ Headset automatisch afspelen ReplayGain Waarschuwing: Als u de voorversterker op een hoge positieve waarde zet, kan dit bij sommige audiotracks tot pieken leiden. - Afspelen vanaf getoond item + Afspelen vanaf getoond item Afspeelstatus herstellen Herstel de eerder opgeslagen afspeelstatus (indien aanwezig) Kan status niet herstellen @@ -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 @@ -246,7 +245,7 @@ Genre Ampersand (&) Bewerken - Aflopend + Aflopend Kan status niet wissen Afspeellijst-afbeelding voor %s Geen nummers @@ -271,7 +270,7 @@ Verwijderen Scheiders met meerdere waarden Verberg bijdragers - Speel vanuit genre + Speel vanuit genre Datum toegevoegd %1$s, %2$s Afspeellijst %d @@ -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 592ef9dc7..c33f1e852 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -49,11 +49,10 @@ ਇਕੋਲਾਈਜ਼ਰ ਚਲਾਓ ਸ਼ਫਲ - ਸ਼ਫਲ ਚੁਣਿਆ ਗਿਆ ਕਤਾਰ ਅਗਲਾ ਚਲਾਓ ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ - ਐਲਬਮ \'ਤੇ ਜਾਓ + ਐਲਬਮ \'ਤੇ ਜਾਓ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਵੇਖੋ ਗੀਤ ਦੀਆਂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਪੇਰੈਂਟ ਮਾਰਗ @@ -75,9 +74,8 @@ ਐਂਡਰੌਇਡ ਲਈ ਇੱਕ ਸਰਲ, ਤਰਕਸੰਗਤ ਸੰਗੀਤ ਪਲੇਅਰ। ਖੋਜੋ ਗੀਤ ਦੀ ਗਿਣਤੀ - ਘਟਦੇ ਹੋਏ - ਚੁਣਿਆ ਹੋਇਆ ਚਲਾਓ - ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ + ਘਟਦੇ ਹੋਏ + ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ ਫਾਈਲ ਦਾ ਨਾਮ ਬਿੱਟ ਰੇਟ ਸੈਂਪਲ ਰੇਟ @@ -95,11 +93,11 @@ ਐਪ ਦਾ ਥੀਮ ਅਤੇ ਰੰਗ ਬਦਲੋ ਥੀਮ ਸਵੈਚਾਲਿਤ - ਹਲਕਾ + ਸਫ਼ੈਦ ਗੂੜ੍ਹਾ ਰੰਗ ਸਕੀਮ ਕਾਲ੍ਹਾ ਥੀਮ - ਇੱਕ ਸ਼ੁੱਧ-ਕਾਲ੍ਹਾ ਗੂੜ੍ਹਾ ਥੀਮ ਵਰਤੋ + ਸ਼ਾਹ-ਕਾਲ਼ਾ ਥੀਮ ਵਰਤੋ ਗੋਲ ਮੋਡ ਵਾਧੂ UI ਤੱਤਾਂ \'ਤੇ ਗੋਲ ਕੋਨਿਆਂ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ (ਗੋਲਾਕਾਰ ਕਰਨ ਲਈ ਐਲਬਮ ਕਵਰਾਂ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ ) ਵਿਅਕਤੀਗਤ ਬਣਾਓ @@ -112,13 +110,13 @@ ਅਗਲੇ \'ਤੇ ਜਾਓ ਦੁਹਰਾਓ ਮੋਡ ਵਿਵਹਾਰ - ਜਦੋਂ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਚਲਾਉਂਦੇ ਹਾਂ - ਜਦੋਂ ਆਈਟਮ ਦੇ ਵੇਰਵਿਆਂ ਤੋਂ ਚਲਾਉਂਦੇ ਹਾਂ - ਦਿਖਾਈ ਗਈ ਆਈਟਮ ਤੋਂ ਚਲਾਓ - ਸਾਰੇ ਗੀਤਾਂ ਤੋਂ ਚਲਾਓ - ਐਲਬਮ ਤੋਂ ਚਲਾਓ - ਕਲਾਕਾਰ ਤੋਂ ਖੇਡੋ - ਸ਼ੈਲੀ ਤੋਂ ਖੇਡੋ + ਜਦੋਂ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਚਲਾਉਂਦੇ ਹਾਂ + ਜਦੋਂ ਆਈਟਮ ਦੇ ਵੇਰਵਿਆਂ ਤੋਂ ਚਲਾਉਂਦੇ ਹਾਂ + ਦਿਖਾਈ ਗਈ ਆਈਟਮ ਤੋਂ ਚਲਾਓ + ਸਾਰੇ ਗੀਤਾਂ ਤੋਂ ਚਲਾਓ + ਐਲਬਮ ਤੋਂ ਚਲਾਓ + ਕਲਾਕਾਰ ਤੋਂ ਖੇਡੋ + ਸ਼ੈਲੀ ਤੋਂ ਖੇਡੋ ਸ਼ਫਲ ਯਾਦ ਰੱਖੋ ਗੀਤ ਦੁਹਰਾਉਣ ਤੇ ਰੋਕੋ ਰੀਪਲੇਅ-ਗੇਨ @@ -289,4 +287,18 @@ ਤੁਹਾਡੀ ਸੰਗੀਤ ਲਾਇਬਰੇਰੀ ਲੋਡ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… (%1$d/%2$d) ਵਰਗੀਕ੍ਰਿਤ ਐਲਬਮ ਕਵਰ ਫੋਰਸ ਕਰੋ ਸਾਰੇ ਐਲਬਮ ਕਵਰਾਂ ਨੂੰ 1:1 ਦੇ ਆਕਾਰ ਅਨੁਪਾਤ ਤੱਕ ਕਾਂਟ-ਛਾਂਟ ਕਰੋ + ਗੀਤ + ਵੇਖੋ + ਇਸੇ ਗੀਤ ਨੂੰ ਚਲਾਓ + ਸੌਰਟ ਕਰੋ + ਦਿਸ਼ਾ + ਚੋਣ + ਚੋਣ ਚਿੱਤਰ + ਹੋਰ + ਤਰੁੱਟੀ ਦੀ ਜਾਣਕਾਰੀ + ਕਾਪੀ ਕੀਤਾ ਗਿਆ + ਰਿਪੋਰਟ ਕਰੋ + ਕੋਈ ਐਲਬਮ ਨਹੀਂ + ਡੈਮੋ + ਡੈਮੋ \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d433b61c4..737838fe6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -20,8 +20,8 @@ Odtwórz następny Dodaj do kolejki Dodano do kolejki - Przejdź do wykonawcy - Przejdź do albumu + Przejdź do wykonawcy + Przejdź do albumu O aplikacji Wersja Kod źródłowy @@ -132,8 +132,8 @@ Korektor Rozmiar Brak folderów - Odtwórz wszystkie utwory - Odtwórz album + Odtwórz wszystkie utwory + Odtwórz album Automatycznie odtwórz muzykę po podłączeniu słuchawek (może nie działać na wszystkich urządzeniach) Odśwież muzykę Odśwież bibliotekę muzyczną używając tagów z pamięci cache, jeśli są dostępne @@ -157,7 +157,7 @@ Tryb powtarzania Ustawienie ReplayGain Preferuj album, jeśli jest odtwarzany - Odtwarzanie z widoku biblioteki + Odtwarzanie z widoku biblioteki Zapisz stan odtwarzania Przecinek (,) Średnik (;) @@ -215,8 +215,8 @@ Regulacja w oparciu o tagi Regulacja bez tagów Wzmocnienie dźwięku przez preamplifier jest nakładane na wcześniej ustawione wzmocnienie podczas odtwarzania - Odtwarzanie z widoku szczegółowego - Odtwórz tylko wybrane + Odtwarzanie z widoku szczegółowego + Odtwórz tylko wybrane Zatrzymaj odtwarzanie, kiedy utwór się powtórzy Muzyka będzie importowana tylko z wybranych folderów. Znaki oddzielające wartości @@ -237,7 +237,7 @@ Kontynuuj odtwarzanie losowe po wybraniu nowego utworu Zaimportowane utwory: %d Ignoruj pliki audio które nie są utworami muzycznymi (np. podcasty) - Odtwórz od wykonawcy + Odtwórz od wykonawcy Wyklucz inne pliki dźwiękowe Okładki albumów Wyłączone @@ -255,14 +255,12 @@ 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 Wiki Funkcje - Odtwórz z gatunku + Odtwórz z gatunku Wyczyść pamięć cache z tagami i zaimportuj ponownie bibliotekę (wolniej, ale dokładniej) Zaimportuj ponownie bibliotekę @@ -275,7 +273,7 @@ Muzyka Nie można wyczyścić stanu odtwarzania Nie można zapisać stanu odtwarzania - Malejąco + Malejąco Playlisty Playlista Obraz playlisty %s @@ -302,4 +300,9 @@ Edytowanie %s Przytnij okładki do formatu 1:1 Wymuś kwadratowe okładki + 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 b8e2b2539..6716a415a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -11,16 +11,16 @@ Pesquisar Filtro Tudo - Classificar + Organizar Reproduzir Aleatório Tocando agora Fila - Reproduzir próxima + Reproduzir a seguir Adicionar à fila Adicionada à fila - Ir para o artista - Ir para o álbum + Ir para o artista + Ir para o álbum Sobre Versão Código-fonte @@ -145,7 +145,7 @@ Áudio MPEG-4 Áudio Ogg Áudio Matroska - Codificação de Audio Avançada (AAC) + Advanced Audio Coding (AAC) Free Lossless Audio Codec (FLAC) Mover esta música da fila Dinâmico @@ -163,8 +163,8 @@ Exibição Ativa cantos arredondados em elementos adicionais da interface do usuário Modo de normalização de volume (ReplayGain) - Reproduzir a partir do item mostrado - Reproduzir de todas as músicas + Reproduzir a partir do item mostrado + Reproduzir de todas as músicas Preferir álbum Prefira o álbum se estiver tocando Recarregamento automático @@ -183,7 +183,7 @@ Monitorando alterações na sua biblioteca de músicas… Abas da biblioteca Gênero - Reproduzir do artista + Reproduzir do artista Restaura a lista de reprodução salva anteriormente (se houver) Ajuste em faixas com metadados Lista limpa @@ -193,7 +193,7 @@ Monitorando a biblioteca de músicas Cantos arredondados Pular para o próximo - Reproduzir do álbum + Reproduzir do álbum Salvar lista de reprodução Limpar lista de reprodução Restaurar lista de reprodução @@ -211,8 +211,8 @@ Single remix Conteúdo Faixa - Ao tocar da biblioteca - Ao tocar a partir dos detalhes do item + Ao tocar da biblioteca + Ao tocar a partir dos detalhes do item Mixtapes Mixtape Remixes @@ -253,13 +253,11 @@ 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 %1$s, %2$s - Tocar a partir do gênero + Tocar a partir do gênero Configure o comportamento de som e reprodução Imagens Mude o tema e as cores do aplicativo @@ -272,7 +270,30 @@ Persistência Comportamento Pastas - Descendente + Descendente Ignorar artigos ao classificar Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Playlists + Playlist %d + Playlist + Playlist criada + Mais + Apagar + Copiado + Adicionar à playlist + Compartilhar + Editar + Renomear + Adicionada à playlist + Editando %s + Organizar por + Música + Apagar playlist\? + Criar uma nova playlist + Playlist deletada + Nova playlist + Playlist renomeada + Renomear playlist + Aparece em + Apagar %s\? Esta ação não pode ser desfeita. \ No newline at end of file diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index ff331f33f..e02dbbe6c 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3,25 +3,25 @@ Tentar novamente Permitir - Gêneros + Géneros Artistas Álbuns Músicas Todas as músicas - Pesquisar + Procurar Filtrar Tudo - Classificação + Organizar Ascendente Reproduzir - Embaralhar - Tocando agora + Misturar + A tocar agora Fila Reproduzir a próxima Adicionar à fila Adicionada à fila - Ir para o artista - Ir para o álbum + Ir para o artista + Ir para o álbum Sobre Versão Código fonte @@ -34,10 +34,10 @@ Claro Escuro Automático - Cor de realce + Esquema de cores Áudio - Comportamento - Memorizar aleatorização + Personalizar + Memorizar musica misturada Nenhuma música encontrada @@ -71,58 +71,58 @@ %d Álbuns %d Álbuns - Reproduzir a partir do item mostrado - Reproduzir do álbum + Reproduzir a partir do item mostrado + Reproduzir do álbum Imagem do artista para %s Gênero desconhecido Nenhuma faixa Áudio MPEG-4 Artistas carregados: %d Duração total: %s - Falha no carregamento da música + Falha ao carregar música Nome - Prefira o álbum se estiver tocando - Nenhuma aplicação encontrada que possa lidar com esta tarefa + Prefira o álbum se estiver a tocar + Nenhuma aplicação encontrada que possa executar esta tarefa Ciano Contagem de músicas Formato Estatísticas da biblioteca Capa do álbum - Ano + Data Rápido Qualidade alta - Ação da barra de reprodução personalizada + Personalizar a barra de reprodução Modo de repetição - Reproduzir do artista + Reproduzir do artista Pausar na repetição O Auxio precisa de permissão para ler a sua biblioteca de músicas Sem pastas Esta pasta não é compatível Mover esta música da fila Remover pasta - Compilações de remix + Mistura de compilações Compilação ao vivo Disco Faixa Taxa de bits - Pular para o próximo + Avançar para o próximo Aviso: Alterar o pré-amplificador para um valor positivo alto pode resultar em picos em algumas faixas de áudio. - Ajuste com etiquetas + Ajustar com etiquetas Barra (/) Mais (+) Áudio Ogg Data adicionada Taxa de amostragem - Gravar + Salvar Separadores multi-valor Nome do ficheiro Tamanho - Ver propriedades + Propriedades Propriedades da música OK Adicionar Estado salvo - Estado liberado + Estado limpo Tema preto Limpar consulta de pesquisa Imagem de gênero para %s @@ -139,8 +139,8 @@ Ao vivo Duração Cancelar - A carregar a sua biblioteca de músicas… - Gira de onde a música deve ser carregada + A carregar biblioteca de músicas… + Configurar onde a música deve ser carregada Gênero Mantenha a reprodução aleatória ao reproduzir uma nova música Pular para a próxima música @@ -152,7 +152,7 @@ Excluir A música não será carregada das pastas que adicionar. Incluir - A música somente será carregada das pastas que adicionar. + A música será somente carregada das pastas que adicionar. Excluir não-música Ignorar ficheiros de áudio que não são música, tal como podcasts Configurar caracteres que denotam múltiplos valores de etiqueta @@ -167,30 +167,30 @@ Dinâmico Disco %d Capa do álbum para %s - Ajuste sem etiquetas + Ajustar sem etiquetas Conteúdo Gêneros carregados: %d A carregar música A carregar música - A monitorar a biblioteca de música + A monitorizar a biblioteca de música Equalizador Um reprodutor de música simples e racional para Android. Estado restaurado - Exibição + Mostrar Abas da biblioteca Altere a visibilidade e a ordem das abas da biblioteca Capas de álbuns Desligado Modo redondo - Usar ação de notificação alternativa - Reprodução automática do fone de ouvido - Sempre comece a tocar quando um fone de ouvido estiver conectado (pode não funcionar em todos os aparelhos) + Personalizar notificações + Reprodução automática dos auscultadores + Iniciar música quando os auscultadores forem conectados (pode não funcionar em todos os aparelhos) Estratégia do ganho de repetição Preferir álbum O pré-amplificador é aplicado ao ajuste existente durante a reprodução - Reproduzir de todas as músicas - Pausa quando uma música se repete - Limpe o estado de reprodução salvo anteriormente (se houver) + Reproduzir de todas as músicas + Pausar quando uma música é repetida + Limpar o estado de reprodução salvo anteriormente (se houver) Restaurar o estado de reprodução Restaurar o estado de reprodução salvo anteriormente (se houver) Ativar ou desativar a reprodução aleatória @@ -207,8 +207,8 @@ Mixtape Remixes Artista - Gravar estado de reprodução - Salve o estado de reprodução atual agora + Gravar estado da reprodução + Salvar o estado de reprodução atual Limpar estado de reprodução Álbum ao vivo -%.1f dB @@ -217,40 +217,38 @@ 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 - Mixes - Mix + Misturas DJ + DJ Mix Aleatório - Ocultar artistas colaboradores + Ocultar colaboradores Limpa os metadados em cache e recarrega totalmente a biblioteca de música (lento, porém mais completo) Álbum de Remix Single ao vivo Single remix - Monitorando alterações na sua biblioteca de músicas… + A Monitorizar alterações na sua biblioteca de músicas… Recarrega a biblioteca de músicas sempre que ela mudar (requer notificação fixa) - Redefinir + Repor Wiki - Visualize e controle a reprodução de música - Use um tema preto + Vêr e controlar a reprodução da música + Utilizar tema preto puro Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos) Preferir faixa Pré-amplificação da normalização de volume - Ao tocar a partir dos detalhes do item - Tocar a partir do gênero + Ao tocar a partir dos detalhes do item + Tocar a partir do gênero Retrocede a música antes de voltar para a anterior Recarregar música %1$s, %2$s Não foi possível limpar a lista Não foi possível gravar a lista - Re-escanear músicas + Procurar músicas novamente Nenhuma lista pode ser restaurada Ícone do Auxio - Aleatorizar tudo - Ao tocar da biblioteca + Misturar tudo + Ao tocar da biblioteca Singles Single Recarrega a biblioteca de músicas usando metadados salvos em cache quando possível @@ -259,20 +257,55 @@ %d artistas %d artistas - Equalização de volume ReplayGain - Descendente - Mude o tema e as cores do app - Personalize os controles e o comportamento da interface do usuário - Controle como a música e as imagens são carregadas + Configurar ganho de repetição + Descendente + Mudar o tema e cores da app + Personalize os controlos e o comportamento do interface do utilizador + Controlar como a música e as imagens são carregadas Música Imagens - Configurar som e comportamento de reprodução + Configurar o som e comportamento da reprodução Reprodução Pastas Biblioteca - Estado de reprodução + Estado da reprodução E comercial (&) Comportamento - Ignorar artigos ao classificar + Classificação inteligente Ignore palavras como \"the\" ao classificar por nome (funciona melhor com músicas em inglês) + Direção + Seleção de imagem + Seleção + Tocar música sozinha + Listas de reprodução + Lista de reprodução %d + Lista de reprodução + Lista de reprodução criada + Mais + Imagem da lista de reprodução de %s + Eliminar + Nenhum disco + Copiado + Adicionar à lista de reprodução + Partilhar + Editar + Renomear + Adicionado à lista de reprodução + Nenhuma música + Recortar à capa dos álbuns numa proporção de 1:1 + A editar %s + Ordenar por + Visualizar + Música + Eliminar lista de reprodução + Criar nova lista de reprodução + Lista de reprodução eliminada + Relatório + Nova lista de reprodução + Informações de erro + Forçar capas em formato quadrado + Lista de reprodução renomeada + Renomear lista de reprodução + Excluir %s\? Não pode ser desfeito. + Só aparecer \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml new file mode 100644 index 000000000..3a0906840 --- /dev/null +++ b/app/src/main/res/values-pt/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 1ef092e66..e892e2739 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -20,8 +20,8 @@ Redă următoarea Adăugați la lista de așteptare A fost adăugat la lista de așteptare - Mergi la artist - Accesaţi albumul + Mergi la artist + Accesaţi albumul Despre Versiune Cod sursă @@ -134,29 +134,61 @@ 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ă + Descrescător Treceți la următoarea - Redă de la artist - Redă din genul + Redă de la artist + Redă din genul Resetează Wiki Vizualizați și controlați redarea muzicii Schimbă vizibilitatea și ordinea taburilor din bibliotecă Taburi din bibliotecă Nu uita de shuffle - Redă din toate melodiile - În timpul redării din bibliotecă - Redă de la articolul afișat + Redă din toate melodiile + În timpul redării din bibliotecă + Redă de la articolul afișat Conținut Acțiune de notificare personalizată Menține funcția shuffle activată la redarea unei melodii noi Personalizarea acțiunii bării de redare Modul de repetare - Redă din album - În timpul redării de la detaliile articolului + Redă din album + În timpul redării de la detaliile articolului Comportament + Listă de redare nouă + Ignoră fișiere audio care nu sunt muzică, precum podcasturi + Plus (+) + Melodie + Listă de redare creată + Șterge + Ascunde colaboratori + Oprit + Taie toate coperțile de album la raportul de aspect 1:1 + Sortare inteligentă + Redenumiți lista da redare + Șterge lista de redare\? + Redenumiți + Controlează cum muzica și imaginile sunt încărcate + Sortează după + Sortare corectă pentru nume care incep cu numere sau cuvinte precum \"the\" (funcționează cel mai bine cu melodii în limba engleză) + Forțează coperți de album pătrate + Rapid + Calitate mare + Punct și virgulă (;) + Editează + Exclude non-muzică + Adaugat către lista de redare + Reîncărcare automată + Virgulă (,) + Reîncărcați biblioteca de muzică oricând se schimbă (Necesită notificare persistentă) + Imagini + Apare în + Partajați + Listă de redare redenumită + Listă de redare ștearsă + Coperți de album + Adaugă către listă de redare + Direcție \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 43daae2e2..4859680ad 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -23,15 +23,15 @@ Сейчас играет Играть Перемешать - Играть все композиции - Играть альбом - Играть исполнителя + Играть все композиции + Играть альбом + Играть исполнителя Очередь Играть далее Добавить в очередь Добавлено в очередь - Перейти к исполнителю - Перейти к альбому + Перейти к исполнителю + Перейти к альбому Позиция сохранена Добавить Сохранить @@ -65,7 +65,7 @@ По альбому Предпочитать по альбому, если он воспроизводится Поведение - При воспроизведении из библиотеки + При воспроизведении из библиотеки Запоминать перемешивание Запоминать режим перемешивания для новых треков Сначала перемотать трек @@ -146,17 +146,17 @@ Перемешать Перемешать всё ОК - При воспроизведении из сведений - Воспроизведение с показанного элемента - Номер песни + При воспроизведении из сведений + Воспроизведение с показанного элемента + Номер трека Битрейт Диск Трек Позиция восстановлена Отмена Внимание: Изменение предусиления на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках. - Свойства - Свойства песни + Сведения + Свойства трека Путь Формат Размер @@ -258,13 +258,11 @@ Не удалось очистить состояние Не удалось сохранить состояние Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\). - Воспроизвести выбранное - Перемешать выбранное %d выбрано Вики Сбросить %1$s,%2$s - Играть жанр + Играть жанр Поведение Выравнивание громкости ReplayGain Музыка @@ -277,7 +275,7 @@ Воспроизведение Папки Состояние воспроизведения - По убыванию + По убыванию Плейлист Плейлисты Обложка плейлиста для %s @@ -304,4 +302,18 @@ Поделиться Использовать квадратные обложки альбомов Обрезать все обложки альбомов до соотношения сторон 1:1 + Трек + Вид + Воспроизвести трек отдельно + Сортировать по + Направление + Выберите + Выберите изображение + Дополнительно + Информация об ошибке + Отчёт об ошибке + Скопировано + Няма альбомаў + Демо + Дэманстрацыі \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml new file mode 100644 index 000000000..5d8da013d --- /dev/null +++ b/app/src/main/res/values-sl/strings.xml @@ -0,0 +1,307 @@ + + + Siva + Pametno sortiranje + Zbirke + Albumi + Zbirka remiksov + Počisti stanje predvajanja + Pojdi na album + Slika izvajalca za %s + Smer + Dodano v čakalno vrsto + Svetlo + Remiks album + Wiki + Samodejno + Slika izbire + Izbira + Preskoči na naslednjo pesem + Ime + Črna tema + Prikaži samo izvajalce, ki so neposredno navedeni na albumu (najbolje deluje v dobro označenih knjižnicah) + Zavihki knjižnice + Temna vijolična + Predvajaj pesem samostojno + Prednost albumu + Predvajaj iz prikazanega elementa + Samodejno ponovno nalaganje + Ni mogoče počistiti stanja + Podaljšane + Lastnosti pesmi + Spremenite način ponavljanja + Oranžna + Dodaj + Naključno predvajanje + DJ Miks + Prednost pesmi + Glasba ni v predavanju + Žanri + Preprost, racionalen predvajalnik glasbe za Android. + Nalaganje vaše glasbene knjižnice… + Previj nazaj pred skokom na prejšnjo pesem + %d kbps + Spremljanje vaše glasbene knjižnice za spremembe… + Prednost albumu če se album predvaja + Seznami predvajanja + Išči v knjižnici… + Ko se predvaja iz podrobnosti elementa + Ponovno naloži glasbo + Remiksi + Shrani stanje predvajanja + Opozorilo: Sprememba pred-ojačevalca na visoko pozitivno vrednost lahko privede do preseganja na nekaterih avdio posnetkih. + Ni datuma + Ponovno naloži glasbeno knjižnico, uporabi predpomnjene oznake, kadar je mogoče + Najdena ni bila nobena aplikacija, ki bi lahko opravila to nalogo + Prekliči + Vključi + Seznam predvajanja %d + Shrani trenutno stanje predvajanja zdaj + Preskoči na zadnjo pesem + Ponovno naloži glasbeno knjižnico vsakič, ko se zazna sprememba (zahteva vztrajno obvestilo) + Pot do datoteke + + %d pesem + %d pesmi + %d pesmi + %d pesmi + + Podaljšano v živo + Seznam predvajanja + Zbirka + Skrij soustvarjalce + Obdrži naključno predvajanje pri predvajanju nove pesmi + Obnašanje + Izklopljeno + MPEG-4 Audio + Shrani + Odpri čakalno vrsto + Mešanice + Izvajalec + Pravilno razvrsti imena, ki se začnejo z številkami ali besedami, kot so \'the\' (najbolje deluje z angleško glasbo) + Ime datoteke + Zelenkasto modra + Vztrajnost + Premešaj vse pesmi + Seznam predavanja ustvarjen + Celoten čas predvajanja: %s + Ni mogoče shraniti stanja + Pavza ob ponavljanju + Mape za glasbo + Zapomni si naključno predvajanje + Pojdi na izvajalca + Naloženih pesmi: %d + Premakni to pesem + Spremljanje glasbene knjižnice + Pokaži več + Ciano modra + Barvna shema + Slika seznama predvajanja za %s + Odstrani + Previj nazaj preden se preskoči nazaj + Naloženih žanrov: %d + Se predvaja + Odstrani to pesem + Stanje predvajanja shranjeno + Ni diska + Išči + Vedno začnite predvajati, ko se slušalke priključijo (morda ne deluje na vseh napravah) + Glasbene podlage + Premešaj vse + Dodaj v čakalno vrsto + Pred-ojačevalnik ReplayGain + MPEG-1 Audio + Ni mogoče obnoviti stanja + Spremenite temo in barve aplikacije + Poskusi znova + Prilagodi zvok in obnašanje predvajanja + Nadzorujte kako se glasba in slike nalagajo + Izgled in občutek + Izključi + Matroska Audio + Začasna prekinitev ob ponavljanju + Predvajaj + Nalaganje glasbe + Ni najdenih pesmi + Datum + Izprazni predpomnilnik oznak in popolnoma ponovno naloži glasbeno knjižnico (počasneje, vendar bolj popolno) + Pred-ojačevalec se uporablja na obstoječi prilagoditvi med predvajanjem + Predvajaj iz albuma + Glasba + Ta mapa ni podprta + Obnovi prej shranjeno stanje predvajanja (če obstaja) + Razvil Alexander Capehart + Odstrani mapo + Kopirano + Nalaganje glasbe ni uspelo + Album + Ko se predvaja iz knjižnice + Visoka kvaliteta + Prilagoditev brez oznak + Dodaj na seznam predvajanja + Datum vnosa + Deli + Album v živo + Uredi + Naslovnica albuma + Preimenuj + Plus (+) + Stanje predvajanja obnovljeno + %d Izbrano + Neznan izvajatelj + Slike + + %d izvajalec + %d izvajalca + %d izvajalci + %d izvajalcev + + Stanje predvajanja počiščeno + Prikaz + %1$s, %2$s + Ogg Audio + Vse + Poševnica (/) + Dodano na seznam predvajanja + Singl v živo + Ni pesmi + Podaljšano + Pesmi + Mape + Prireži vse naslovnice albumov v razmerje 1:1 + Prilagojeno dejanje na vrstici za predvajanje + Indigo modra + -%.1f dB + Nalaganje glasbe + In (&) + Število pesmi + Sortiraj + Vijolična + Brezplačni format brez izgub zvoka (FLAC) + Neznan žanr + +%.1f dB + Prilagodi + Urejanje %s + Preskoči na naslednjo + Način ponavljanja + + %d album + %d albuma + %d albumi + %d albumov + + Disk + Nalaganje vaše glasbene knjižnice… (%1$d/%2$d) + Počisti iskalno poizvedbo + Naraščajoče + Roza + Vse pesmi + O aplikaciji + Disk %d + Omogočite zaobljene robove na dodatnih elementih uporabniškega vmesnika (zahteva zaobljene naslovnice albumov) + Naslovnica albuma za %s + V živo + Spremenite vidnost in vrstni red zavihkov knjižnice + Počisti shranjeno stanje predvajanja (če obstaja) + Sortiraj po + Ogled + Ustavi predvajanje + Mežanica + Glasba se bo nalagala samo iz map, ki jih dodate. + Način + Remiks singla + Auxio potrebuje dovoljenje za branje vaše glasbene knjižnice + Tema + Knjižnica + Statistika knjižnice + Izenačevalnik + Premakni ta zavihek + Pesem + Slika žanra za %s + Nastavitev virov za nalaganje glasbe + DJ Miksi + Odstrani seznam predvajanja\? + Modra + Temno modra + Zaobljen način + Naloženih izvajalcev: %d + Zvok + Glasba se ne bo nalagala iz the map, ki jih dodate. + Rdeča + Dinamično + Temno + Vklopite ali izklopite naključno predvajanje + Žanr + Predvajanje + Vredu + Ustvari nov seznam predvajanja + Singl + Seznam predvajanja odstranjen + Dovoli + Predvajaj iz vseh pesmi + Obnovi stanje predvajanja + Prilagoditev z oznakami + Predvajanje ob priključitvi slušalk + Vejica (,) + Auxio ikona + Skladba %d + Filtriraj + Prezri avdio datoteke, ki niso glasba, na primer podkaste + Glasbena podlaga + Prilagojeno dejanje v obvestilu + Ponastavi nastavitve + Prijavi napako + Izključi ne-glasbo + Predvajaj naslednje + Izvajalci + Skladba + Naloženih albumov: %d + Nov seznam predvajanja + Informacije napake + Premešaj + Napredno avdio kodiranje (AAC) + Prisilite uporabo kvadratnih naslovnic albumov + Zbirka pesmi v živo + Naslovnice albumov + Rumena + Zelena + Različica + Ogled lastnosti + Velikost + Padajoče + Podaljšan remiks + Podpičje (;) + Hitro + Seznam predvajanja preimenovan + Predvajaj ali začasno ustavi + Rjava + ReplayGain strategija + Izvorna koda + Predvajaj iz izvajalca + Ni map + Prilagoditev kontrol uporabniškega vmesnika in obnašanja + Hitrost vzorčenja + Čakalna vrsta + Ločila za več vrednosti + ReplayGain Tehnologija + Singli + Pregled in nadzor predvajanja glasbe + Uporabite čisto črno temo + Ponovno preglej glasbeno knjižnico + Licence + Format + Vsebina + Preimenuj seznam predvajanja + Bitna hitrost + Odstraniti %s\? Tega ni mogoče razveljaviti. + Nastavitve + Konfigurirajte znake, ki označujejo več vrednosti zaporedoma + Ni skladbe + Opozorilo: Uporaba te nastavitve lahko povzroči, da se nekatere oznake napačno interpretirajo kot oznake z več vrednostmi. To lahko rešite tako, da neželene ločevalne znake predhodno označite z vzvratno poševnico (\\). + Temno zelena + Predvajaj iz žanra + Trajanje + Limeta + Sodeloval pri + %d Hz + \ 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 7acaab1b1..16af4e8f1 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -3,7 +3,7 @@ Försök igen Musik laddar Laddar musik - Alla låtar + Alla spår Album Albumet Remix-album @@ -23,7 +23,7 @@ Remixar Framträder på Konstnär - Konstnär + Konstnärer Genrer Spellista Spellistor @@ -39,17 +39,16 @@ Spår Datum tillagt Stigande - Fallande + Fallande Nu spelar Utjämnare Spela - Spela utvalda Blanda Spela nästa Lägg till spellista - Gå till konstnär - Gå till album + Gå till konstnär + Gå till album Visa egenskaper Dela Egenskaper för låt @@ -69,11 +68,11 @@ Licenser Visa och kontrollera musikuppspelning Laddar ditt musikbibliotek… - Övervakning ditt musikbibliotek för ändringar… - Tillagd till kö + Overvåker ditt musikbibliotek för ändringar… + Tillagd i kö Spellista skapade Tillagd till spellista - Sök ditt musikbibliotek… + Sök i ditt musikbibliotek… Inställningar Utseende Ändra tema och färger på appen @@ -84,7 +83,7 @@ Bevilja En enkel, rationell musikspelare för Android. Övervakar musikbiblioteket - Låtar + Spår Live-album Ta bort Live-sammanställning @@ -99,7 +98,6 @@ Alla Disk Sortera - Blanda utvalda Lägg till kö Filnamn Lägg till @@ -109,7 +107,7 @@ Tillstånd sparat Version Statistik över beroende - Bytt namn av spellista + Byt namn av spellista Spellista tog bort Utvecklad av Alexander Capeheart Tema @@ -124,10 +122,10 @@ Hoppa till nästa Upprepningsmodus Beteende - När spelar från artikeluppgifter - Spela från genre + När spelar från artikeluppgifter + Spela från genre Komma ihåg blandningsstatus - Behåll blandning på när spelar en ny låt + Behåll blandning på när en ny låt spelas Kontent Kontrollera hur musik och bilar laddas Musik @@ -141,11 +139,11 @@ Dölj medarbetare Skärm Bibliotekflikar - När spelar från biblioteket - Spela från visad artikel - Spela från alla låtar - Spela från konstnär - Spela från album + När spelar från biblioteket + Spela från visad artikel + Spela från alla låtar + Spela från konstnär + Spela från album Semikolon (;) Ladda om musikbiblioteket när det ändras (kräver permanent meddelande) Komma (,) @@ -153,4 +151,146 @@ Konfigurera tecken som separerar flera värden i taggar Advarsel: Denna inställning kan leda till att vissa taggar separeras felaktigt. För att åtgärda detta, prefixa oönskade separatortecken med ett backslash (\\). Anpassa UI-kontroller och beteende + Av + Hörlurar-autouppspelning + Pausa när en låt upprepas + Musik laddas inte från mapparna som ni lägger till. + Öppna kö + Dynamisk + %d konstnärer som laddats + + %d konstnär + %d konstnärer + + Bildar + Ljud + Konfigurera ljud- och uppspelningsbeteende + Spola tillbaka innan spår hoppar tillbaka + ReplayGain-strategi + Rensa det tidigare sparade uppspelningsläget om det finns + Återställ uppspelningsläge + -%.1f dB + Radera %s\? Detta kan inte ångras. + Endast visa artister som är direkt krediterade på ett album (funkar bäst på välmärkta bibliotek) + Albumomslag + Snabbt + Bibliotek + Inkludera + Uppdatera musik + Ladda musikbiblioteket om och använd cachad taggar när det är möjligt + Uthållighet + Rensa uppspelningsläge + Återställ det tidigare lagrade uppspelningsläget om det finns + Misslyckades att spara uppspelningsläget + Blanda alla spår + Rensa sökfrågan + Radera mappen + Genrebild för %s + Spellistabild för %s + MPEG-1-ljud + MPEG-4-ljud + OGG-ljud + Matroska-ljud + Blå + Mörkblå + Cyanblå + Blågrön + Grön + Mörkgrön + Limegrön + Gul + Grå + %1$s, %2$s + Redigerar %s + Uppspelning + Orange + Brun + Alltid börja uppspelning när hörlurar kopplas till (kanske inte fungerar på alla enheter) + Pausa vid upprepa + ReplayGain förförstärkare + Justering utan taggar + Musikmappar + Varning: Om man ändrar förförstärkaren till ett högt positivt värde kan det leda till toppning på vissa ljudspår. + Hantera var musik bör laddas in från + Mappar + Modus + Utesluta + Musik laddas endast från mapparna som ni lägger till. + Spara det aktuella uppspelningsläget + Skanna musik om + Rensa tagbiblioteket och ladda komplett om musikbiblioteket (långsammare, men mer komplett) + Ingen musik på gång + Laddning av musik misslyckades + Auxio behöver tillstånd för att läsa ditt musikbibliotek + Ingen app på gång som kan hantera denna uppgift + Denna mapp stöds inte + Misslyckades att återställa uppspelningsläget + Spår %d + Spela eller pausa + Flytta detta spår + Okänd konstnär + Okänd genre + Avancerad audio-koding (AAC) + %d utvalda + Spellista %d + +%.1f dB + + %d spår + %d spår + + + %d album + %d album + + %d spår som laddats + Total längd: %s + Kopierade + Urval + Felinformation + Rapportera + Ingen datum + Ingen disk + Inget spår + Inga spår + Lilla + %d kbps + %d Hz + %d album som laddats + %d genrer som laddats + Spela upp låten själv + Hög kvalitet + Tvinga fyrkantiga skivomslag + Beskär alla albumomslag till en 1:1 sidförhållande + Spola tillbaka innan att hoppa till föregående låt + Justering med taggar + Inga mappar + Misslyckades att rensa uppspelningsläget + Skapa en ny spellista + Stoppa uppspelning + Radera detta spår + Auxio-ikon + Flytta denna flik + Albumomslag + Urvalbild + Mörklila + Indigo + Disk %d + Spara uppspelningsläge + Hoppa till nästa spår + Hoppa till sista spår + Ändra upprepningsläge + Slå på eller av blandningen + Albumomslag för %s + Konstnärbild för %s + Ingen musik spelas + Fritt tapsfritt ljudkodek (FLAC) + Rosa + Laddar ditt musikbibliotek… (%1$d/%2$d) + Ampersand (&) + ReplayGain + Föredra spår + Föredra album + Föredra album om ett album spelar + Förförstarkning användas för befintliga justeringar vid uppspelning + Röd \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 99d462df2..5e77b99ab 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -20,8 +20,8 @@ Sonraki şarkı Kuyruğa ekle Kuyruğa eklendi - Sanatçıya git - Albüme git + Sanatçıya git + Albüme git Hakkında Sürüm Kaynak kodu @@ -92,9 +92,9 @@ Etiketsiz ayarla Etiket ile ayarla Uyarı: Ön amfinin yüksek bir pozitif değere değiştirilmesi bazı ses parçalarında pik yapmaya neden olabilir. - Gösterilen öğeden çal - Tüm şarkılardan çal - Albümden çal + Gösterilen öğeden çal + Tüm şarkılardan çal + Albümden çal Müzik klasörleri Müzik yalnızca eklediğiniz klasörlerden yüklenecektir. %s Albümünün kapağı @@ -112,9 +112,9 @@ Kireç Sarı Turuncu - Kitaplıktan çalarken - Öğe ayrıntılarından çalarken - Sanatçıdan çal + Kitaplıktan çalarken + Öğe ayrıntılarından çalarken + Sanatçıdan çal Yüklenen sanatçılar: %d Yüklenen türler: %d %d Hz @@ -196,10 +196,8 @@ Tekliler Tekli Karışık kaset - Seçileni çal - Karışık seçildi Canlı derleme - Remiks derlemeler + Remiks derlemesi Ekolayzır Canlı EP Remiks EP @@ -230,8 +228,8 @@ %d sanatçı %d sanatçılar - Karmalar - Karma + DJ Miksleri + DJ Mix Etiket önbelleğini temizleyin ve müzik kitaplığını tamamen yeniden yükleyin (daha yavaş, ancak daha eksiksiz) Çok değerli ayırıcılar Birden fazla etiket değerini ifade eden karakterleri yapılandırın @@ -248,7 +246,7 @@ Eğik çizgi (/) Kuyruğu aç Tekrar kipi - Türden çal + Türden çal Podcast\'ler gibi müzik olmayan ses dosyalarını yok say Uyarı: Bu ayarın kullanılması bazı etiketlerin yanlışlıkla birden fazla değere sahip olarak yorumlanmasına neden olabilir. Bunu, istenmeyen ayırıcı karakterlerin önüne ters eğik çizgi (\\) koyarak çözebilirsiniz. Müzik olmayanları hariç tut @@ -263,7 +261,7 @@ Oynatma Kütüphane Kalıcılık - Azalan + Azalan Uygulamanın temasını ve renklerini değiştirin Klasörler Arayüz kontrollerini ve davranışını özelleştirin @@ -280,4 +278,30 @@ Yeniden Adlandır Oynatma Listesini Yeniden Adlandır Oynatma listesini silmek istiyor musun\? + Yön + Seçim görüntüsü + Seçim + Şarkıyı kendi kendine çal + Çalma listesi %d + Oynatma listesi oluşturuldu + Daha fazla + Disk yok + Kopyalandı + Çalma listesine ekle + Paylaş + Düzenle + Çalma listesine eklendi + Şarkı yok + Tüm albüm kapaklarını 1:1 en boy oranına kırp + %s düzenleniyor + Göre sırala + Görünüm + Şarkı + Çalma listesi silindi + Rapor + Hata bilgisi + Kare albüm kapaklarına zorla + Çalma listesi yeniden adlandırıldı + %s silinsin mi\? Geri alınamaz. + Üzerinde görünür \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8f7435242..2164136ed 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,8 +19,8 @@ Відтворити наступним Додати в чергу Додано в чергу - Перейти до виконавця - Перейти до альбому + Перейти до виконавця + Перейти до альбому Про застосунок Версія Вихідний код @@ -54,7 +54,6 @@ %d альбомів %d альбомів - Перемішати вибране Ім\'я файлу Формат Добре @@ -83,7 +82,6 @@ Шлях до каталогу Екран Рік - Відтворити вибране Обкладинки альбомів Приховати співавторів Вимкнено @@ -143,14 +141,14 @@ Режим повторення Режим Попередній підсилювач ReplayGain - Відтворити альбом - При відтворенні з бібліотеки + Відтворити альбом + При відтворенні з бібліотеки Віддавати перевагу альбому, якщо він відтворюється Стан відтворення очищено Використовувати повністю чорну тему Показувати лише тих виконавців, які безпосередньо зазначені в альбомі (найкраще працює в добре позначених бібліотеках) Увага: Встановлення високих позитивних значень попереднього підсилювача може призвести до спотворення звуку в деяких піснях. - При відтворенні з деталей предмета + При відтворенні з деталей предмета Очистити кеш тегів і повністю перезавантажити музичну бібліотеку (повільніше, але ефективніше) Автоматичне перезавантаження Перезавантажувати бібліотеку при виявленні змін (потрібне постійне сповіщення) @@ -164,10 +162,10 @@ Відстеження змін в музичній бібліотеці… Власна дія для панелі відтворення Регулювання без тегів - Відтворення з показаного елемента + Відтворення з показаного елемента Продовжити перемішування після вибору нової пісні - Відтворити виконавця - Відтворити жанр + Відтворити виконавця + Відтворити жанр Перемотати назад перед відтворенням попередньої пісні Зберегти поточний стан відтворення Пересканувати музику @@ -187,7 +185,7 @@ Перемотайте на початок пісні перед відтворенням попередньої Увімкнути заокруглені кути на додаткових елементах інтерфейсу (потрібно заокруглення обкладинок альбомів) Попередній підсилювач застосовується до наявних налаштувань під час відтворення - Відтворити всі пісні + Відтворити всі пісні Перезавантажити музичну бібліотеку, використовуючи кешовані теги, коли це можливо Скісна риска (/) Плюс (+) @@ -274,7 +272,7 @@ Стан відтворення Налаштуйте звук і поведінку при відтворенні Папки - За спаданням + За спаданням Зображення списку відтворення для %s Список відтворення Списки відтворення @@ -299,6 +297,20 @@ Редагування %s Немає диску З\'являється на - Обрізання обкладинки альбомів до співвідношення сторін 1:1 + Обрізання обкладинок альбомів до співвідношення сторін 1:1 Примусові квадратні обкладинки + Пісня + Переглянути + Відтворити пісню окремо + Сортувати за + Напрямок + Вибрати + Вибрати зображення + Докладніше + Інформація про помилку + Скопійовано + Звіт + Альбомів немає + Демо + Демонстрації \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4a5290943..f3742f1d0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,15 +23,15 @@ 正在播放 播放 随机 - 从全部歌曲开始播放 - 从专辑开始播放 - 从艺术家播放 + 从全部歌曲开始播放 + 从专辑开始播放 + 从艺术家播放 播放队列 作为下一首播放 加入播放队列 已加入播放队列 - 查看艺术家 - 查看专辑 + 查看艺术家 + 查看专辑 已保存播放进度 添加 保存 @@ -65,7 +65,7 @@ 偏好专辑 如果已有专辑正在播放则优先增益专辑 行为 - 从音乐库中选择播放时 + 从音乐库中选择播放时 记住随机模式 播放新曲目时保留随机播放模式 切换上一曲前先倒带 @@ -162,9 +162,9 @@ 已加载艺术家数量:%d 已加载流派数量:%d 总计时长:%s - 从展示的项目播放 + 从展示的项目播放 仅从您添加的目录中加载音乐。 - 从项目详情中选择播放时 + 从项目详情中选择播放时 不会从您添加的目录中加载音乐。 高级音乐编码 (AAC) 已加载专辑数量:%d @@ -249,10 +249,8 @@ 无法清除状态 重新扫描音乐 清除标签缓存并完全重新加载音乐库(更慢,但更完整) - 随机播放所选 - 播放所选 选中了 %d 首 - 按流派播放 + 按流派播放 Wiki %1$s, %2$s 重置 @@ -268,7 +266,7 @@ 文件夹 音乐 配置声音和播放行为 - 降序 + 降序 播放列表 播放列表 %s 的播放列表图片 @@ -295,4 +293,18 @@ 正在编辑 %s 强制使用方形专辑封面 将所有专辑封面裁剪至 1:1 宽高比 + 歌曲 + 查看 + 自行播放歌曲 + 排序依据 + 说明 + 选择 + 选择图片 + 报告 + 更多 + 已复制 + 错误信息 + 无专辑 + 演示 + 样曲 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 345d98a3e..c59791d5c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -20,8 +20,8 @@ 下一首播放 添加到隊列 已加入隊列 - 前往該歌手頁面 - 專輯 + 前往該歌手頁面 + 專輯 關於 版本 在 GitHub 上檢視 @@ -72,4 +72,5 @@ 專輯 單曲 單曲 + 在更多的用戶界面元素上啟用圓角(需要專輯封面也要設定圓角) \ 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 5295acb05..140539c9b 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -11,7 +11,7 @@ 48dp - 56dp + 56dp 128dp 192dp 256dp @@ -23,6 +23,7 @@ 48dp 56dp 64dp + 64dp 72dp 24dp diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index f3c956766..eb07550e3 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -27,8 +27,8 @@ auxio_pre_amp_with auxio_pre_amp_without - auxio_library_playback_mode - auxio_detail_playback_mode + auxio_play_in_list_with + auxio_play_in_parent_with KEY_KEEP_SHUFFLE KEY_PREV_REWIND KEY_LOOP_PAUSE @@ -42,7 +42,7 @@ auxio_bar_action auxio_notif_action - KEY_SEARCH_FILTER + KEY_SEARCH_FILTER auxio_songs_sort auxio_albums_sort @@ -106,34 +106,38 @@ @integer/action_mode_shuffle - - @string/set_playback_mode_songs - @string/set_playback_mode_artist - @string/set_playback_mode_album - @string/set_playback_mode_genre + + @string/set_play_song_from_all + @string/set_play_song_from_album + @string/set_play_song_from_artist + @string/set_play_song_from_genre + @string/set_play_song_by_itself - - @integer/music_mode_songs - @integer/music_mode_artist - @integer/music_mode_album - @integer/music_mode_genre + + @integer/play_song_from_all + @integer/play_song_from_album + @integer/play_song_from_artist + @integer/play_song_from_genre + @integer/play_song_by_itself - - @string/set_playback_mode_none - @string/set_playback_mode_songs - @string/set_playback_mode_artist - @string/set_playback_mode_album - @string/set_playback_mode_genre + + @string/set_play_song_none + @string/set_play_song_from_all + @string/set_play_song_from_album + @string/set_play_song_from_artist + @string/set_play_song_from_genre + @string/set_play_song_by_itself - - @integer/music_mode_none - @integer/music_mode_songs - @integer/music_mode_artist - @integer/music_mode_album - @integer/music_mode_genre + + @integer/play_song_none + @integer/play_song_from_all + @integer/play_song_from_album + @integer/play_song_from_artist + @integer/play_song_from_genre + @integer/play_song_by_itself @@ -152,11 +156,12 @@ 1 2 - -2147483648 - 0xA108 - 0xA109 - 0xA10A - 0xA10B + -2147483648 + 0xA11F + 0xA120 + 0xA121 + 0xA122 + 0xA124 0xA111 0xA112 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 95e6c348e..c7866d184 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,10 +16,13 @@ Monitoring music library Retry + + More Grant Songs + Song All songs Albums @@ -60,6 +63,10 @@ Mixtapes Mixtape + + Demo + + Demos DJ Mixes @@ -103,15 +110,15 @@ Date added Sort + Sort by + Direction Ascending - Descending + Descending Now playing Equalizer Play - Play selected Shuffle - Shuffle selected Queue Play next @@ -119,9 +126,10 @@ Add to playlist - Go to artist - Go to album + Go to artist + Go to album View properties + View Share Song properties @@ -162,6 +170,14 @@ Licenses Library statistics + Selection + + Error information + + Copied + + Report + @@ -205,13 +221,14 @@ Skip to next Repeat mode Behavior - When playing from the library - When playing from item details - Play from shown item - Play from all songs - Play from album - Play from artist - Play from genre + When playing from the library + When playing from item details + Play from shown item + Play from all songs + Play from album + Play from artist + Play from genre + Play song by itself Remember shuffle Keep shuffle on when playing a new song @@ -329,6 +346,7 @@ Artist image for %s Genre image for %s Playlist image for %s + Selection image @@ -339,6 +357,7 @@ No disc No track No songs + No albums No music playing diff --git a/app/src/main/res/values/styles_core.xml b/app/src/main/res/values/styles_core.xml index 446c3064f..f43098404 100644 --- a/app/src/main/res/values/styles_core.xml +++ b/app/src/main/res/values/styles_core.xml @@ -23,6 +23,9 @@ @style/Theme.Auxio.Dialog @style/Widget.Auxio.Slider @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 @@ -49,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 5ee16b401..5106e12b9 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -31,6 +31,38 @@ @dimen/size_corners_medium + + + + + + + + + + diff --git a/app/src/main/res/xml/preferences_personalize.xml b/app/src/main/res/xml/preferences_personalize.xml index dca8a8dfb..a9f3b9b64 100644 --- a/app/src/main/res/xml/preferences_personalize.xml +++ b/app/src/main/res/xml/preferences_personalize.xml @@ -29,19 +29,19 @@ . - */ - -package org.oxycblt.auxio.music - -import android.net.Uri -import org.oxycblt.auxio.music.fs.MimeType -import org.oxycblt.auxio.music.fs.Path -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 - -open class FakeSong : Song { - override val name: Name - get() = throw NotImplementedError() - override val date: Date? - get() = throw NotImplementedError() - override val dateAdded: Long - get() = throw NotImplementedError() - override val disc: Disc? - get() = throw NotImplementedError() - override val genres: List - get() = throw NotImplementedError() - override val mimeType: MimeType - get() = throw NotImplementedError() - override val track: Int? - get() = throw NotImplementedError() - override val path: Path - get() = throw NotImplementedError() - override val size: Long - get() = throw NotImplementedError() - override val uri: Uri - get() = throw NotImplementedError() - override val album: Album - get() = throw NotImplementedError() - override val artists: List - get() = throw NotImplementedError() - override val durationMs: Long - get() = throw NotImplementedError() - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeAlbum : Album { - override val name: Name - get() = throw NotImplementedError() - override val coverUri: Uri - get() = throw NotImplementedError() - override val dateAdded: Long - get() = throw NotImplementedError() - override val dates: Date.Range? - get() = throw NotImplementedError() - override val releaseType: ReleaseType - get() = throw NotImplementedError() - override val artists: List - get() = throw NotImplementedError() - override val durationMs: Long - get() = throw NotImplementedError() - override val songs: List - get() = throw NotImplementedError() - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeArtist : Artist { - override val name: Name - get() = throw NotImplementedError() - override val albums: List - get() = throw NotImplementedError() - override val explicitAlbums: List - get() = throw NotImplementedError() - override val implicitAlbums: List - get() = throw NotImplementedError() - override val genres: List - get() = throw NotImplementedError() - override val durationMs: Long - get() = throw NotImplementedError() - override val songs: List - get() = throw NotImplementedError() - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeGenre : Genre { - override val name: Name - get() = throw NotImplementedError() - override val artists: List - get() = throw NotImplementedError() - override val durationMs: Long - get() = throw NotImplementedError() - override val songs: List - get() = throw NotImplementedError() - override val uid: Music.UID - get() = throw NotImplementedError() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt deleted file mode 100644 index 4af3e64b3..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicRepository.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 - -import kotlinx.coroutines.Job -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary - -open class FakeMusicRepository : MusicRepository { - override val indexingState: IndexingState? - get() = throw NotImplementedError() - override val deviceLibrary: DeviceLibrary? - get() = throw NotImplementedError() - override val userLibrary: UserLibrary? - get() = throw NotImplementedError() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun registerWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun find(uid: Music.UID): Music? { - throw NotImplementedError() - } - - override suspend fun createPlaylist(name: String, songs: List) { - throw NotImplementedError() - } - - override suspend fun renamePlaylist(playlist: Playlist, name: String) { - throw NotImplementedError() - } - - override suspend fun deletePlaylist(playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun addToPlaylist(songs: List, playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { - throw NotImplementedError() - } - - override fun requestIndex(withCache: Boolean) { - throw NotImplementedError() - } - - override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean): Job { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt deleted file mode 100644 index 66cd8e880..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicSettings.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 - -import org.oxycblt.auxio.list.Sort -import org.oxycblt.auxio.music.fs.MusicDirectories - -open class FakeMusicSettings : MusicSettings { - override fun registerListener(listener: MusicSettings.Listener) = throw NotImplementedError() - override fun unregisterListener(listener: MusicSettings.Listener) = throw NotImplementedError() - override var musicDirs: MusicDirectories - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override val excludeNonMusic: Boolean - get() = throw NotImplementedError() - override val shouldBeObserving: Boolean - get() = throw NotImplementedError() - override var multiValueSeparators: String - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override val intelligentSorting: Boolean - get() = throw NotImplementedError() - override var songSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var albumSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var artistSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var genreSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var playlistSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var albumSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var artistSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - override var genreSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt deleted file mode 100644 index 1cd68bb51..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicModeTest.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 - -import org.junit.Assert.assertEquals -import org.junit.Test - -class MusicModeTest { - @Test - fun intCode() { - assertEquals(MusicMode.SONGS, MusicMode.fromIntCode(MusicMode.SONGS.intCode)) - assertEquals(MusicMode.ALBUMS, MusicMode.fromIntCode(MusicMode.ALBUMS.intCode)) - assertEquals(MusicMode.ARTISTS, MusicMode.fromIntCode(MusicMode.ARTISTS.intCode)) - assertEquals(MusicMode.GENRES, MusicMode.fromIntCode(MusicMode.GENRES.intCode)) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt deleted file mode 100644 index 8ad02dbb6..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicViewModelTest.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 - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.device.FakeDeviceLibrary -import org.oxycblt.auxio.util.forceClear - -class MusicViewModelTest { - @Test - fun indexerState() { - val indexer = - TestMusicRepository().apply { - indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) - } - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - assertTrue(indexer.updateListener is MusicViewModel) - assertTrue(indexer.indexingListener is MusicViewModel) - assertEquals( - IndexingProgress.Indeterminate, - (musicViewModel.indexingState.value as IndexingState.Indexing).progress) - indexer.indexingState = null - assertEquals(null, musicViewModel.indexingState.value) - musicViewModel.forceClear() - assertTrue(indexer.indexingListener == null) - } - - @Test - fun statistics() { - val musicRepository = TestMusicRepository() - val musicViewModel = MusicViewModel(musicRepository, FakeMusicSettings()) - assertEquals(null, musicViewModel.statistics.value) - musicRepository.deviceLibrary = TestDeviceLibrary() - assertEquals( - MusicViewModel.Statistics( - 2, - 3, - 4, - 1, - 161616 * 2, - ), - musicViewModel.statistics.value) - } - - @Test - fun requests() { - val indexer = TestMusicRepository() - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - musicViewModel.refresh() - musicViewModel.rescan() - assertEquals(listOf(true, false), indexer.requests) - } - - private class TestMusicRepository : FakeMusicRepository() { - override var deviceLibrary: DeviceLibrary? = null - set(value) { - field = value - updateListener?.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - override var indexingState: IndexingState? = null - set(value) { - field = value - indexingListener?.onIndexingStateChanged() - } - - var updateListener: MusicRepository.UpdateListener? = null - var indexingListener: MusicRepository.IndexingListener? = null - val requests = mutableListOf() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - this.updateListener = listener - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - this.updateListener = null - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - listener.onIndexingStateChanged() - this.indexingListener = listener - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - this.indexingListener = null - } - - override fun requestIndex(withCache: Boolean) { - requests.add(withCache) - } - } - - private class TestDeviceLibrary : FakeDeviceLibrary() { - override val songs: List - get() = listOf(TestSong(), TestSong()) - override val albums: List - get() = listOf(FakeAlbum(), FakeAlbum(), FakeAlbum()) - override val artists: List - get() = listOf(FakeArtist(), FakeArtist(), FakeArtist(), FakeArtist()) - override val genres: List - get() = listOf(FakeGenre()) - } - - private class TestSong : FakeSong() { - override val durationMs: Long - get() = 161616 - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt new file mode 100644 index 000000000..9914dbe5f --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023 Auxio Project + * CacheRepositoryTest.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.cache + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerifyAll +import io.mockk.coVerifySequence +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import java.lang.IllegalStateException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.info.Date + +class CacheRepositoryTest { + @Test + fun cache_read_noInvalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val songA = RawSong(mediaStoreId = 0, dateAdded = 1, dateModified = 2) + assertTrue(cache.populate(songA)) + assertEquals(RAW_SONG_A, songA) + + assertFalse(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertFalse(cache.invalidated) + } + + @Test + fun cache_read_invalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val nullStart = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + val nullEnd = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + assertFalse(cache.populate(nullStart)) + assertEquals(nullStart, nullEnd) + + assertTrue(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertTrue(cache.invalidated) + } + + @Test + fun cache_read_crashes() { + val dao = mockk { coEvery { readSongs() } throws IllegalStateException() } + val cacheRepository = CacheRepositoryImpl(dao) + assertEquals(null, runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + } + + @Test + fun cache_write() { + var currentlyStoredSongs = listOf() + val insertSongsArg = slot>() + val dao = + mockk { + coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() } + + coEvery { insertSongs(capture(insertSongsArg)) } answers + { + currentlyStoredSongs = insertSongsArg.captured + } + } + + val cacheRepository = CacheRepositoryImpl(dao) + + val rawSongs = listOf(RAW_SONG_A, RAW_SONG_B) + runBlocking { cacheRepository.writeCache(rawSongs) } + + val cachedSongs = listOf(CACHED_SONG_A, CACHED_SONG_B) + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(cachedSongs) + } + assertEquals(cachedSongs, currentlyStoredSongs) + } + + @Test + fun cache_write_nukeCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } throws IllegalStateException() + coEvery { insertSongs(listOf()) } just Runs + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifyAll { dao.nukeSongs() } + } + + @Test + fun cache_write_insertCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } just Runs + coEvery { insertSongs(listOf()) } throws IllegalStateException() + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(listOf()) + } + } + + private companion object { + val CACHED_SONG_A = + CachedSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val RAW_SONG_A = + RawSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val CACHED_SONG_B = + CachedSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + + val RAW_SONG_B = + RawSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt deleted file mode 100644 index 2c4805486..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * DeviceMusicImplTest.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.device - -import java.util.UUID -import org.junit.Assert.assertTrue -import org.junit.Test - -class DeviceMusicImplTest { - @Test - fun albumRaw_equals_inconsistentCase() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian Glow"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian glow"))) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun albumRaw_equals_withMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"), - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_inconsistentMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_withArtists() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist A"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist B"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentCase() { - val a = RawArtist(musicBrainzId = null, name = "Parannoul") - val b = RawArtist(musicBrainzId = null, name = "parannoul") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_withMbids() { - val a = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = - RawArtist( - musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"), - name = "Artist") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentMbids() { - val a = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = RawArtist(musicBrainzId = null, name = "Artist") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_missingNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = "Parannoul") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentCase() { - val a = RawGenre("Future Garage") - val b = RawGenre("future garage") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_missingNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = "Future Garage") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt b/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt deleted file mode 100644 index d08e04615..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeDeviceLibrary.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.device - -import android.content.Context -import android.net.Uri -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.Song - -open class FakeDeviceLibrary : DeviceLibrary { - override val songs: List - get() = throw NotImplementedError() - override val albums: List - get() = throw NotImplementedError() - override val artists: List - get() = throw NotImplementedError() - override val genres: List - get() = throw NotImplementedError() - - override fun findSong(uid: Music.UID): Song? { - throw NotImplementedError() - } - - override fun findSongForUri(context: Context, uri: Uri): Song? { - throw NotImplementedError() - } - - override fun findAlbum(uid: Music.UID): Album? { - throw NotImplementedError() - } - - override fun findArtist(uid: Music.UID): Artist? { - throw NotImplementedError() - } - - override fun findGenre(uid: Music.UID): Genre? { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 075df1b1c..b63639e27 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -88,32 +88,4 @@ class DateTest { assertEquals(null, Date.from("2016-08-16:00:01:02")) assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) } - - @Test - fun dateRange_from_correct() { - val range = - requireNotNull( - Date.Range.from( - listOf( - requireNotNull(Date.from("2016-08-16T00:01:02")), - requireNotNull(Date.from("2016-07-16")), - requireNotNull(Date.from("2014-03-12T00")), - requireNotNull(Date.from("2022-12-22T22:22:22"))))) - assertEquals("2014-03-12T00Z", range.min.toString()) - assertEquals("2022-12-22T22:22:22Z", range.max.toString()) - } - - @Test - fun dateRange_from_one() { - val range = - requireNotNull( - Date.Range.from(listOf(requireNotNull(Date.from("2016-08-16T00:01:02"))))) - assertEquals("2016-08-16T00:01:02Z", range.min.toString()) - assertEquals("2016-08-16T00:01:02Z", range.max.toString()) - } - - @Test - fun dateRange_from_none() { - assertEquals(null, Date.Range.from(listOf())) - } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt index 260ca67cb..9b428acac 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt @@ -19,30 +19,36 @@ package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class DiscTest { @Test - fun disc_compare() { - val a = Disc(1, "Part I") - val b = Disc(2, "Part II") - assertEquals(-1, a.compareTo(b)) + fun disc_equals_byNum() { + val a = Disc(0, null) + val b = Disc(0, null) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) } @Test - fun disc_equals_correct() { - val a = Disc(1, "Part I") - val b = Disc(1, "Part I") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) + fun disc_equals_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(0, "a subtitle") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) } @Test - fun disc_equals_inconsistentNames() { - val a = Disc(1, "Part I") + fun disc_compareTo_byNum() { + val a = Disc(0, null) val b = Disc(1, null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun disc_compareTo_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(1, "a subtitle") + assertEquals(-1, a.compareTo(b)) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt new file mode 100644 index 000000000..078a1f154 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -0,0 +1,437 @@ +/* + * Copyright (c) 2023 Auxio Project + * NameTest.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.info + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class NameTest { + @Test + fun name_simple_withoutPunct() { + val name = Name.Known.SimpleFactory.parse("Loveless", null) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_simple_withPunct() { + val name = Name.Known.SimpleFactory.parse("alt-J", null) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_simple_oopsAllPunct() { + val name = Name.Known.SimpleFactory.parse("!!!", null) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_simple_spacedPunct() { + val name = Name.Known.SimpleFactory.parse("& Yet & Yet", null) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_simple_withSort() { + val name = Name.Known.SimpleFactory.parse("The Smile", "Smile") + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("Loveless", null) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { + val name = Name.Known.IntelligentFactory.parse("15 Step", null) + assertEquals("15 Step", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("15", first.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Step", second.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { + val name = Name.Known.IntelligentFactory.parse("23Kid", null) + assertEquals("23Kid", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("23", first.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Kid", second.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { + val name = Name.Known.IntelligentFactory.parse("Foo 1 2 Bar", null) + assertEquals("Foo 1 2 Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("2", fourth.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("Bar", fifth.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fifth.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { + val name = Name.Known.IntelligentFactory.parse("Foo12Bar", null) + assertEquals("Foo12Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("12", second.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals("Bar", third.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { + val name = Name.Known.IntelligentFactory.parse("Foo 1", null) + assertEquals("Foo 1", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { + val name = Name.Known.IntelligentFactory.parse("Error404", null) + assertEquals("Error404", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Error", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("404", second.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_intelligent_withoutPunct_withThe_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("The National Anthem", null) + assertEquals("The National Anthem", name.raw) + assertEquals(null, name.sort) + assertEquals("N", name.thumb) + val first = name.sortTokens[0] + assertEquals("National Anthem", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_intelligent_withoutPunct_withAn_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("An Eagle in Your Mind", null) + assertEquals("An Eagle in Your Mind", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Eagle in Your Mind", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_intelligent_withoutPunct_withA_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("A Song For Our Fathers", null) + assertEquals("A Song For Our Fathers", name.raw) + assertEquals(null, name.sort) + assertEquals("S", name.thumb) + val first = name.sortTokens[0] + assertEquals("Song For Our Fathers", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_intelligent_withPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("alt-J", null) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("!!!", null) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_intelligent_withoutPunct_shortArticle_withNumerics() { + val name = Name.Known.IntelligentFactory.parse("the 1", null) + assertEquals("the 1", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("1", first.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, first.type) + } + + @Test + fun name_intelligent_spacedPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.IntelligentFactory.parse("& Yet & Yet", null) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_intelligent_withPunct_withoutArticle_withNumerics() { + val name = Name.Known.IntelligentFactory.parse("Design : 2 : 3", null) + assertEquals("Design : 2 : 3", name.raw) + assertEquals(null, name.sort) + assertEquals("D", name.thumb) + val first = name.sortTokens[0] + assertEquals("Design", first.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("2", second.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("3", fourth.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, fourth.type) + } + + @Test + fun name_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { + val name = Name.Known.IntelligentFactory.parse("2 + 2 = 5", null) + assertEquals("2 + 2 = 5", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("2", first.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals(" ", second.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) + val third = name.sortTokens[2] + assertEquals("2", third.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, third.type) + val fourth = name.sortTokens[3] + assertEquals(" ", fourth.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("5", fifth.collationKey.sourceString) + assertEquals(SortToken.Type.NUMERIC, fifth.type) + } + + @Test + fun name_intelligent_withSort() { + val name = Name.Known.IntelligentFactory.parse("The Smile", "Smile") + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_equals_simple() { + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", "Same") + assertEquals(a, b) + } + + @Test + fun name_equals_differentSort() { + val a = Name.Known.SimpleFactory.parse("The Same", "Same") + val b = Name.Known.SimpleFactory.parse("The Same", null) + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_equals_intelligent_differentTokens() { + val a = Name.Known.IntelligentFactory.parse("The Same", "Same") + val b = Name.Known.IntelligentFactory.parse("Same", "Same") + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.SimpleFactory.parse("A", null) + val b = Name.Known.SimpleFactory.parse("B", null) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.SimpleFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.SimpleFactory.parse("Acid Rain", null) + val c = Name.Known.SimpleFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.SimpleFactory.parse("Breathe In", null) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { + val a = Name.Known.SimpleFactory.parse("15 Step", null) + val b = Name.Known.SimpleFactory.parse("128 Harps", null) + val c = Name.Known.SimpleFactory.parse("1969", null) + assertEquals(1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + } + + @Test + fun name_compareTo_simple_withPartialSort() { + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withSort() { + val a = Name.Known.SimpleFactory.parse("D", "A") + val b = Name.Known.SimpleFactory.parse("C", "B") + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.IntelligentFactory.parse("A", null) + val b = Name.Known.IntelligentFactory.parse("B", null) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.IntelligentFactory.parse("A Brain in a Bottle", null) + val b = Name.Known.IntelligentFactory.parse("Acid Rain", null) + val c = Name.Known.IntelligentFactory.parse("Boralis / Contrastellar", null) + val d = Name.Known.IntelligentFactory.parse("Breathe In", null) + assertEquals(1, a.compareTo(b)) + assertEquals(1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { + val a = Name.Known.IntelligentFactory.parse("15 Step", null) + val b = Name.Known.IntelligentFactory.parse("128 Harps", null) + val c = Name.Known.IntelligentFactory.parse("1969", null) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, b.compareTo(c)) + assertEquals(-2, a.compareTo(c)) + } + + @Test + fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { + val a = Name.Known.SimpleFactory.parse("A", "C") + val b = Name.Known.SimpleFactory.parse("B", null) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { + val a = Name.Known.IntelligentFactory.parse("D", "A") + val b = Name.Known.IntelligentFactory.parse("C", "B") + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_unknown() { + val a = Name.Unknown(0) + assertEquals("?", a.thumb) + } + + @Test + fun name_compareTo_mixed() { + val a = Name.Unknown(0) + val b = Name.Known.IntelligentFactory.parse("A", null) + assertEquals(-1, a.compareTo(b)) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt index 9ca019a40..1294e3daf 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/ReleaseTypeTest.kt @@ -36,6 +36,7 @@ class ReleaseTypeTest { assertEquals(ReleaseType.Soundtrack, ReleaseType.parse(listOf("album", "soundtrack"))) assertEquals(ReleaseType.Mix, ReleaseType.parse(listOf("album", "dj-mix"))) assertEquals(ReleaseType.Mixtape, ReleaseType.parse(listOf("album", "mixtape/street"))) + assertEquals(ReleaseType.Demo, ReleaseType.parse(listOf("album", "demo"))) } @Test diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt new file mode 100644 index 000000000..440f044e3 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Auxio Project + * SeparatorsTest.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 org.junit.Assert.assertEquals +import org.junit.Test + +class SeparatorsTest { + @Test + fun separators_split_withString_withSingleChar() { + assertEquals(listOf("a", "b", "c"), Separators.from(",").split(listOf("a,b,c"))) + } + + @Test + fun separators_split_withMultiple_withSingleChar() { + assertEquals(listOf("a,b", "c", "d"), Separators.from(",").split(listOf("a,b", "c", "d"))) + } + + @Test + fun separators_split_withString_withMultipleChar() { + assertEquals( + listOf("a", "b", "c", "d", "e", "f"), + Separators.from(",;/+&").split(listOf("a,b;c/d+e&f"))) + } + + @Test + fun separators_split_withList_withMultipleChar() { + assertEquals( + listOf("a,b;c/d", "e&f"), Separators.from(",;/+&").split(listOf("a,b;c/d", "e&f"))) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index db340f187..7c900d42c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -20,27 +20,8 @@ package org.oxycblt.auxio.music.metadata import org.junit.Assert.assertEquals import org.junit.Test -import org.oxycblt.auxio.music.FakeMusicSettings class TagUtilTest { - @Test - fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(TestMusicSettings(","))) - } - - @Test - fun parseMultiValue_many() { - assertEquals( - listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(TestMusicSettings(","))) - } - - @Test - fun parseMultiValue_several() { - assertEquals( - listOf("a", "b", "c", "d", "e", "f"), - listOf("a,b;c/d+e&f").parseMultiValue(TestMusicSettings(",;/+&"))) - } - @Test fun splitEscaped_correct() { assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' }) @@ -131,43 +112,30 @@ class TagUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("176", "178", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(TestMusicSettings(","))) + assertEquals(null, listOf("2941").parseId3GenreNames()) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(TestMusicSettings(","))) - } - - @Test - fun parseId3v2Genre_singleSeparated() { - assertEquals( - listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames()) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(TestMusicSettings(","))) - } - - class TestMusicSettings(private val separators: String) : FakeMusicSettings() { - override var multiValueSeparators: String - get() = separators - set(_) = throw NotImplementedError() + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames()) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index 6cd22fdcb..9966c16e9 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -39,6 +39,7 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["APIC"]) } @Test @@ -51,10 +52,24 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) } @Test - fun textTags_combined() { + fun textTags_mp4() { + val textTags = TextTags(MP4_METADATA) + assertTrue(textTags.vorbis.isEmpty()) + assertEquals(listOf("Wheel"), textTags.id3v2["TIT2"]) + assertEquals(listOf("Paraglow"), textTags.id3v2["TALB"]) + assertEquals(listOf("Parannoul", "Asian Glow"), textTags.id3v2["TPE1"]) + assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) + assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) + assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) + } + + @Test + fun textTags_id3v2_vorbis_combined() { val textTags = TextTags(VORBIS_METADATA.copyWithAppendedEntriesFrom(ID3V2_METADATA)) assertEquals(listOf("Wheel"), textTags.vorbis["title"]) assertEquals(listOf("Paraglow"), textTags.vorbis["album"]) @@ -62,10 +77,13 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) + assertEquals(listOf("Wheel"), textTags.id3v2["TIT2"]) assertEquals(listOf("Paraglow"), textTags.id3v2["TALB"]) assertEquals(listOf("Parannoul", "Asian Glow"), textTags.id3v2["TPE1"]) assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) + assertEquals(null, textTags.id3v2["APIC"]) assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) } @@ -90,6 +108,19 @@ class TextTagsTest { TextInformationFrame("TPE1", null, listOf("Parannoul", "Asian Glow")), TextInformationFrame("TDRC", null, listOf("2022")), TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")), + TextInformationFrame("TXXX", "replaygain_track_gain", listOf("+2 dB")), + ApicFrame("", "", 0, byteArrayOf())) + + // MP4 atoms are mapped to ID3v2 text information frames by ExoPlayer, but can + // duplicate frames and have ---- mapped to InternalFrame. + private val MP4_METADATA = + Metadata( + TextInformationFrame("TIT2", null, listOf("Wheel")), + TextInformationFrame("TALB", null, listOf("Paraglow")), + TextInformationFrame("TPE1", null, listOf("Parannoul")), + TextInformationFrame("TPE1", null, listOf("Asian Glow")), + TextInformationFrame("TDRC", null, listOf("2022")), + TextInformationFrame("TXXX", "MusicBrainz Album Type", listOf("ep")), InternalFrame("com.apple.iTunes", "replaygain_track_gain", "+2 dB"), ApicFrame("", "", 0, byteArrayOf())) } diff --git a/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt new file mode 100644 index 000000000..cdbbc6af9 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/user/DeviceLibraryTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2023 Auxio Project + * DeviceLibraryTest.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.user + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.device.AlbumImpl +import org.oxycblt.auxio.music.device.ArtistImpl +import org.oxycblt.auxio.music.device.DeviceLibraryImpl +import org.oxycblt.auxio.music.device.GenreImpl +import org.oxycblt.auxio.music.device.SongImpl + +class DeviceLibraryTest { + + @Test + fun deviceLibrary_withSongs() { + val songUidA = Music.UID.auxio(MusicType.SONGS) + val songUidB = Music.UID.auxio(MusicType.SONGS) + val songA = + mockk { + every { uid } returns songUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val songB = + mockk { + every { uid } returns songUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(songA, songB), listOf(), listOf(), listOf()) + verify { + songA.finalize() + songB.finalize() + } + val foundSongA = deviceLibrary.findSong(songUidA)!! + assertEquals(songUidA, foundSongA.uid) + assertEquals(0L, foundSongA.durationMs) + val foundSongB = deviceLibrary.findSong(songUidB)!! + assertEquals(songUidB, foundSongB.uid) + assertEquals(1L, foundSongB.durationMs) + } + + @Test + fun deviceLibrary_withAlbums() { + val albumUidA = Music.UID.auxio(MusicType.ALBUMS) + val albumUidB = Music.UID.auxio(MusicType.ALBUMS) + val albumA = + mockk { + every { uid } returns albumUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val albumB = + mockk { + every { uid } returns albumUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(), listOf(albumA, albumB), listOf(), listOf()) + verify { + albumA.finalize() + albumB.finalize() + } + val foundAlbumA = deviceLibrary.findAlbum(albumUidA)!! + assertEquals(albumUidA, foundAlbumA.uid) + assertEquals(0L, foundAlbumA.durationMs) + val foundAlbumB = deviceLibrary.findAlbum(albumUidB)!! + assertEquals(albumUidB, foundAlbumB.uid) + assertEquals(1L, foundAlbumB.durationMs) + } + + @Test + fun deviceLibrary_withArtists() { + val artistUidA = Music.UID.auxio(MusicType.ARTISTS) + val artistUidB = Music.UID.auxio(MusicType.ARTISTS) + val artistA = + mockk { + every { uid } returns artistUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val artistB = + mockk { + every { uid } returns artistUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = + DeviceLibraryImpl(listOf(), listOf(), listOf(artistA, artistB), listOf()) + verify { + artistA.finalize() + artistB.finalize() + } + val foundArtistA = deviceLibrary.findArtist(artistUidA)!! + assertEquals(artistUidA, foundArtistA.uid) + assertEquals(0L, foundArtistA.durationMs) + val foundArtistB = deviceLibrary.findArtist(artistUidB)!! + assertEquals(artistUidB, foundArtistB.uid) + assertEquals(1L, foundArtistB.durationMs) + } + + @Test + fun deviceLibrary_withGenres() { + val genreUidA = Music.UID.auxio(MusicType.GENRES) + val genreUidB = Music.UID.auxio(MusicType.GENRES) + val genreA = + mockk { + every { uid } returns genreUidA + every { durationMs } returns 0 + every { finalize() } returns this + } + val genreB = + mockk { + every { uid } returns genreUidB + every { durationMs } returns 1 + every { finalize() } returns this + } + val deviceLibrary = DeviceLibraryImpl(listOf(), listOf(), listOf(), listOf(genreA, genreB)) + verify { + genreA.finalize() + genreB.finalize() + } + val foundGenreA = deviceLibrary.findGenre(genreUidA)!! + assertEquals(genreUidA, foundGenreA.uid) + assertEquals(0L, foundGenreA.durationMs) + val foundGenreB = deviceLibrary.findGenre(genreUidB)!! + assertEquals(genreUidB, foundGenreB.uid) + assertEquals(1L, foundGenreB.durationMs) + } + + @Test + fun deviceLibrary_equals() { + val songA = + mockk { + every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { finalize() } returns this + } + val songB = + mockk { + every { uid } returns Music.UID.auxio(MusicType.SONGS) + every { finalize() } returns this + } + val album = + mockk { + every { uid } returns mockk() + every { finalize() } returns this + } + + val deviceLibraryA = DeviceLibraryImpl(listOf(songA), listOf(album), listOf(), listOf()) + val deviceLibraryB = DeviceLibraryImpl(listOf(songA), listOf(), listOf(), listOf()) + val deviceLibraryC = DeviceLibraryImpl(listOf(songB), listOf(album), listOf(), listOf()) + assertEquals(deviceLibraryA, deviceLibraryB) + assertEquals(deviceLibraryA.hashCode(), deviceLibraryA.hashCode()) + assertNotEquals(deviceLibraryA, deviceLibraryC) + assertNotEquals(deviceLibraryA.hashCode(), deviceLibraryC.hashCode()) + } +} diff --git a/build.gradle b/build.gradle index 5c4648215..fdb033f43 100644 --- a/build.gradle +++ b/build.gradle @@ -1,34 +1,24 @@ buildscript { ext { - kotlin_version = '1.8.22' - navigation_version = "2.6.0" - hilt_version = '2.46.1' - } - - repositories { - google() - mavenCentral() + kotlin_version = '1.9.10' + navigation_version = "2.5.3" + hilt_version = '2.47' } dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" - classpath "com.diffplug.spotless:spotless-plugin-gradle:6.18.0" + // Hilt isn't compatible with the new plugin syntax yet. classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files } } -allprojects { - repositories { - google() - mavenCentral() - } +plugins { + id "com.android.application" version '8.2.0' apply false + id "androidx.navigation.safeargs.kotlin" version "$navigation_version" apply false + id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false + id "com.google.devtools.ksp" version '1.9.10-1.0.13' apply false + id "com.diffplug.spotless" version "6.20.0" apply false } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } \ No newline at end of file diff --git a/fastlane/metadata/android/be/full_description.txt b/fastlane/metadata/android/be/full_description.txt index 25badc179..dc3182282 100644 --- a/fastlane/metadata/android/be/full_description.txt +++ b/fastlane/metadata/android/be/full_description.txt @@ -19,4 +19,4 @@ Auxio - гэта мясцовы музычны плэер з хуткім і н - Аўтазапуск гарнітуры - Стыльныя віджэты, якія аўтаматычна адаптуюцца да іх памеру - Цалкам прыватны і ў аўтаномным рэжыме -- Ніякіх круглявых вокладак альбомаў (Калі вы не хочаце іх. Тады вы можаце.) +- Ніякіх круглявых вокладак альбомаў (па змаўчанні) diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index d2f1c40f5..caf9e51ef 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -1,8 +1,8 @@ -Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na systému ExoPlayer má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu. +Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spousty zbytečných funkcí, které mají ostatní hudební přehrávače. Díky postavení na moderních knihovnách má Auxio lepší podporu knihovny a kvalitu poslechu v porovnání s ostatními aplikacemi, které používají zastaralou funkci systému Android. Ve zkratce prostě přehrává hudbu. Funkce -- Přehrávač založený na systému ExoPlayer +- Přehrávání založené na systému Media3 ExoPlayer - Responzivní UI podle nejnovějších pokynů Material Design - Příjemné UX, které upřednostňuje snadné používání před okrajovými případy - Přizpůsobitelné chování @@ -20,4 +20,4 @@ přesná/původní data, štítky pro řazení a další - Automatické přehrávání při připojení sluchátek - Stylové widgety, které se automaticky adaptují své velikosti - Plně soukromý a offline -- Žádné zakulacené obaly alb (Pokud je tedy nechcete. Jinak jsou k dispozici.) +- Žádné zakulacené obaly alb (ve výchozím nastavení) diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index a9b2d6711..3eedd16a2 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,8 +1,8 @@ -Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und besitzt daher eine erstklassige Musikbibliothek-Unterstützung sowie Wiedergabequalität verglichen mit anderen Playern, die veraltete Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. +Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf modernen Wiedergabebibliotheken und besitzt daher eine erstklassige Musikbibliothek-Unterstützung sowie Wiedergabequalität verglichen mit anderen Playern, die veraltete Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. Funktionen -- auf ExoPlayer basierende Wiedergabe +- auf Media3 ExoPlayer basierende Wiedergabe - elegante, am Material Design orientierte UI - Überzeugende UX, die eine einfache Bedienung über Grenzfälle stellt - Anpassbares Verhalten @@ -20,4 +20,4 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab - Autoplay bei Kopfhörern - Stylische Widgets, die ihre Größe anpassen - vollständig privat und offline -- keine abgerundeten Album-Cover (Außer die willst. Dann geht das.) +- keine abgerundeten Album-Cover (standardmäßig) diff --git a/fastlane/metadata/android/en-US/changelogs/34.txt b/fastlane/metadata/android/en-US/changelogs/34.txt new file mode 100644 index 000000000..64a1da8b4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/34.txt @@ -0,0 +1,3 @@ +Auxio 3.1.0 introduces playlisting functionality, with more features coming soon. +This release fixes several critial UI issues identified in the previous version. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.1.4. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/35.txt b/fastlane/metadata/android/en-US/changelogs/35.txt new file mode 100644 index 000000000..e3514f6dc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/35.txt @@ -0,0 +1,2 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/36.txt b/fastlane/metadata/android/en-US/changelogs/36.txt new file mode 100644 index 000000000..b0ac8dc87 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/36.txt @@ -0,0 +1,3 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +This release fixes several critical issues identified in the previous version. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index ec9c5977d..3f4927359 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,8 +1,8 @@ -Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of ExoPlayer, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, It plays music. +Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, It plays music. Features -- ExoPlayer-based playback +- Playback based on Media3 ExoPlayer - Snappy UI derived from the latest Material Design guidelines - Opinionated UX that prioritizes ease of use over edge cases - Customizable behavior @@ -20,4 +20,4 @@ precise/original dates, sort tags, and more - Headset autoplay - Stylish widgets that automatically adapt to their size - Completely private and offline -- No rounded album covers (Unless you want them. Then you can.) \ No newline at end of file +- No rounded album covers (by default) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index 8fcac190a..1ed0f49ec 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png differ diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 79f379066..67ee646c7 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -1,8 +1,8 @@ -Auxio es un reproductor de música local con una UI/UX rápida y confiable sin las muchas características inútiles presentes en otros reproductores de música. Creado a partir de ExoPlayer, Auxio tiene un soporte de biblioteca y una calidad de escucha superiores en comparación con otras aplicaciones que usan una funcionalidad de Android obsoleta. En resumen, Reproduce música. +Auxio es un reproductor de música local con una UI/UX rápida y confiable sin las muchas características inútiles presentes en otros reproductores de música. Construido a partir de bibliotecas de reproducción de medios modernas, Auxio tiene un soporte de biblioteca y una calidad de escucha superiores en comparación con otras aplicaciones que usan una funcionalidad de Android obsoleta. En resumen, Reproduce música. Características -- Reproducción basada en ExoPlayer +- Reproducción basada en Media3 ExoPlayer - Interfaz de usuario ágil derivada de las últimas pautas de diseño de materiales - UX obstinado que prioriza la facilidad de uso sobre los casos extremos - Comportamiento personalizable @@ -20,4 +20,4 @@ fechas precisas/originales, ordenar etiquetas y más - Reproducción automática de auriculares - Widgets con estilo que se adaptan automáticamente a su tamaño - Completamente privado y fuera de línea -- No hay portadas de álbumes redondeadas (a menos que las quieras. Entonces puedes) +- Sin carátulas redondeadas (por defecto) diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 000000000..107936eaf --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1,23 @@ +Auxio est un lecteur de musique local doté d'une UI/UX rapide et sûre, sans les fonctions inutiles de la plupart des autres lecteurs. Construit sur les bases d'une librairie moderne de lecture de media, Auxio supporte une libaririe et propose une qualité d'écoute supérieurs comparé aux autres applications qui utilisent des fonctionnalités d'android dépassées. Pour faire simple, il joue votre musique . + +Fonctionnalités + +- Lecture basée sur l'ExoPlayer Media3 +- UI réactive dérivée des dernières lignes directrices en Material Design +- UX orientée qui mets l'accent sur la facilité d'utilisation plutôt que sur les usages +- Comportement personnalisable +- Reconnaît les numéros de disque, les artistes multiples, les types de support, +les dates précises/originales, le classement par tags, and plus encore +- Système de reconaissance d'artistes avancé qui unifie artistes et artistes de l'album +- Carte SD reconnue par le système de dossiers +- Fonction de liste de lecture efficace +- Statut de lecture persistant +- Support complet de ReplayGain (pour les fichiers MP3, FLAC, OGG, OPUS, et MP4) +- Support pour égaliseur externe (ex. Wavelet) +- Navigation bord-à-bord +- Couvertures intégrées reconnues +- Recherche intégrée +- Lecture automatique pour les casques +- Widgets stylisés qui s'adaptent automatiquement à leur taille +- Complètement privé et hors-ligne +- On arrondit pas les couvertures d'albums (Sauf si vous le voulez. Dans ce cas c'est possible.) diff --git a/fastlane/metadata/android/he/full_description.txt b/fastlane/metadata/android/he/full_description.txt new file mode 100644 index 000000000..1cadc6eb3 --- /dev/null +++ b/fastlane/metadata/android/he/full_description.txt @@ -0,0 +1,23 @@ +אוקסיו הוא נגן מוזיקה מקומי עם ממשק וחוויית משתמש מהיר ואמין, ובלי כל הפיצ'רים חסרי התועלת הרבים שנמצאים בנגני מוזיקה אחרים. בנוי על ספריות ניגון מדיה מודרניות, לאוקסיו יש תמיכה מיטבית בספריות ואיכות שמע נעלית בהשוואה ליישומים אחרים שמשתמשים בפונקציונליות אנדרואיד מיושנת. בקיצור, הוא פשוט מנגן מוזיקה. + +פיצ'רים + +- ניגון מבוסס על Media3 ExoPlayer +- ממשק משתמש מהיר שנגזר מהנחיות Material Design האחרונות ביותר +- חוויית משתמש שמתעדפת נוחות שימוש על פני מקרי קיצון +- התנהגות מותאמת אישית +- תמיכה במספרי דיסק, אומנים מרובים, סוגי שחרור, +תאריכים מדוייקים/מקוריים, תגיות מיון, ועוד +- מערכת אומנים מתקדמת שמאחדת אומנים ואומני אלבום +- ניהול תיקיות מודע לכרטיסי SD +- פונקציונליות פלייליסטים אמינה +- התמדה במצב ההשמעה +- תמיכה מלאה ב-ReplayGain (בקבצי MP3, FLAC, OGG, OPUS, ו-MP4) +- תמיכה באיקוולייזר חיצוני (למשל, Wavelet) +- קצה לקצה +- תמיכה בעטיפות מוטבעות +- פונקציונליות חיפוש +- ניגון אוטומטי באוזניות +- ווידג'טים אלגנטיים שמתאימים את עצמם לגודלם אוטומטית +- פרטי לגמרי ולא מקוון +- ללא עטיפות אלבום מעוגלות (אלא אם את.ה מעוניינ.ת בהם. אז זה אפשרי.) diff --git a/fastlane/metadata/android/hi/full_description.txt b/fastlane/metadata/android/hi/full_description.txt index b22a5b7c4..81b86358b 100644 --- a/fastlane/metadata/android/hi/full_description.txt +++ b/fastlane/metadata/android/hi/full_description.txt @@ -1,9 +1,9 @@ -Auxio एक तेज़, विश्वसनीय UI/UX वाला एक स्थानीय संगीत प्लेयर है, जिसमें अन्य संगीत प्लेयर में मौजूद कई बेकार सुविधाएँ नहीं हैं। एक्सोप्लेयर से निर्मित, औक्सियो में पुराने एंड्रॉइड कार्यक्षमता का उपयोग करने वाले अन्य ऐप्स की तुलना में बेहतर पुस्तकालय समर्थन और सुनने की गुणवत्ता है। संक्षेप में, +Auxio एक तेज़, विश्वसनीय UI/UX वाला एक स्थानीय संगीत प्लेयर है, जिसमें अन्य संगीत प्लेयर में मौजूद कई बेकार सुविधाएँ नहीं हैं। आधुनिक मीडिया प्लेबैक लाइब्रेरीओं से निर्मित, औक्सियो में पुराने एंड्रॉइड कार्यक्षमता का उपयोग करने वाले अन्य ऐप्स की तुलना में बेहतर पुस्तकालय समर्थन और सुनने की गुणवत्ता है। संक्षेप में, यह संगीत बजाता है. विशेषताएं -- ExoPlayer-आधारित प्लेबैक +- मीडिया3 एक्सोप्लेयर आधारित प्लेबैक - नवीनतम मटीरियल डिज़ाइन दिशानिर्देशों से प्राप्त स्नैपी UI - ओपिनियनेटेड UX जो ओवर एज केस के उपयोग को प्राथमिकता देता है - अनुकूलन योग्य व्यवहार @@ -13,11 +13,11 @@ Auxio एक तेज़, विश्वसनीय UI/UX वाला एक - विश्वसनीय प्लेलिस्टिंग कार्यक्षमता - प्लेबैक अवस्था दृढ़ता - पूर्ण रीप्लेगैन समर्थन (MP3, FLAC, OGG, OPUS और MP4 फ़ाइलों पर) -- बाहरी तुल्यकारक समर्थन (उदा। वेवलेट) +- बाहरी तुल्यकारक समर्थन (उदा: वेवलेट) - एज-टू-एज - एंबेडेड कवर समर्थन - खोज कार्यक्षमता - हेडसेट ऑटोप्ले - स्टाइलिश विजेट जो स्वचालित रूप से अपने आकार के अनुकूल हो जाते हैं - पूरी तरह से निजी और ऑफ़लाइन -- कोई गोलाकार एल्बम कवर नहीं (जब तक आप उन्हें नहीं चाहते। फिर तुम कर सकते हो।) +- कोई गोलाकार एल्बम कवर नहीं (डिफ़ॉल्ट तौर पर) diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index 3973c6e3a..f5f8d2d17 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,8 +1,8 @@ -Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na temelju ExoPlayera, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. +Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na osnovi modernih biblioteka za reprodukciju, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. Značajke -- Reprodukcija temeljena na ExoPlayeru +- Reprodukcija temeljena na Media3 ExoPlayeru - Snappy UI izvedeno iz najnovijih smjernica za materijalni dizajn - Iskustveni korisnički doživljaj koji daje prednost jednostavnosti upotrebe u odnosu na rubne slučajeve - Prilagodljivo ponašanje diff --git a/fastlane/metadata/android/hu/short_description.txt b/fastlane/metadata/android/hu/short_description.txt index 59f70dfa7..f006c9d11 100644 --- a/fastlane/metadata/android/hu/short_description.txt +++ b/fastlane/metadata/android/hu/short_description.txt @@ -1 +1 @@ -A simple, rational music player +Egyszerű, praktikus zenelejátszó diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt index 4a8a34683..7f8f718ce 100644 --- a/fastlane/metadata/android/it/full_description.txt +++ b/fastlane/metadata/android/it/full_description.txt @@ -1,8 +1,8 @@ -Auxio è un lettore musicale locale con un'UI/UX veloce e affidabile, senza le numerose funzioni inutili presenti in altri lettori musicali. Basato su Exoplayer, Auxio ha un supporto di libreria e una qualità di ascolto superiore rispetto ad altre applicazioni che utilizzano funzionalità Android obsolete. In breve, riproduce musica. +Auxio è un lettore musicale locale con un'interfaccia ed espereinza utente veloce e affidabile, senza le numerose funzioni inutili presenti in altri lettori musicali. Basato su moderne librerie di riproduzione multimediale, Auxio ha un supporto di libreria e una qualità di ascolto superiore rispetto ad altre applicazioni che utilizzano funzionalità Android obsolete. In breve, riproduce musica. Caratteristiche -- Riproduzione basata su ExoPlayer +- Riproduzione basata su ExoPlayer di Media3 - Interfaccia utente scattante derivata dalle ultime linee guida del Material Design - Interfaccia utente autorevole che privilegia la facilità d'uso rispetto ai casi limite - Comportamento personalizzabile diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt index 0a1497368..2d5d002c0 100644 --- a/fastlane/metadata/android/ko/full_description.txt +++ b/fastlane/metadata/android/ko/full_description.txt @@ -1,8 +1,8 @@ -Auxio는 다른 음악 플레이어에 존재하는 많은 쓸모없는 기능 없이 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. Exoplayer를 기반으로 구축된 Auxio는 기본 미디어플레이어 API를 사용하는 다른 앱에 비해 훨씬 더 나은 청취 경험을 제공합니다. 즉, 제대로 된 음악을 재생합니다. +Auxio는 다른 음악 플레이어에 있는 쓸모없는 많은 기능 없이 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. 최신 미디어 재생 라이브러리를 기반으로 구축된 Auxio는 오래된 안드로이드 기능을 사용하는 다른 앱에 비해 뛰어난 라이브러리 지원과 청취 품질을 제공합니다. 즉, 제대로 된 음악을 재생합니다. 기능 -- ExoPlayer 기반 재생 +- Media3 ExoPlayer 기반 재생 - 최신주목할 만한 디자인 가이드라인에서 파생된 Snappy UI - 엣지 케이스보다 사용 편의성을 우선시하는 의견이 많은 UX - 사용자 정의 가능한 동작 diff --git a/fastlane/metadata/android/lt/full_description.txt b/fastlane/metadata/android/lt/full_description.txt index 09b33d4cc..043666dfc 100644 --- a/fastlane/metadata/android/lt/full_description.txt +++ b/fastlane/metadata/android/lt/full_description.txt @@ -1,9 +1,9 @@ -Auxio yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis „ExoPlayer“, Auxio turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias Android funkcijas. Trumpai tariant, Jame groja muziką. +Auxio yra vietinis muzikos grotuvas su greita, patikima UI/UX be daugybės nenaudingų funkcijų, esančių kituose muzikos grotuvuose. Sukurta remiantis iš šiuolaikinių medijos grojimo bibliotekų, Auxio turi geresnį bibliotekos palaikymą ir klausymo kokybę, palyginti su kitomis programomis, kurios naudoja pasenusias Android funkcijas. Trumpai tariant, jame groja muziką. Funkcijos -- „ExoPlayer“ pagrįstas grojimas -- Sparti UI, sukurta pagal naujausias „Material Design“ gaires +- Media3 ExoPlayer pagrįstas grojimas +- Sparti UI, sukurta pagal naujausias Material Design gaires - Nuomonę turintis UX, kuriame prioritetas teikiamas naudojimo paprastumui, o ne kraštutiniam atvejui - Pasirinktas elgesys - Palaikomas diskų numerių, kelių atlikėjų, leidinių tipų palaikymas, @@ -12,12 +12,12 @@ tikslias/originalias datas, rūšiavimo žymas ir dar daugiau - SD kortelių aplankų valdymas - Patikima grojaraščių sudarymo funkcija - Grojimo būsenos išsaugojimas -- Visiškas „ReplayGain“ palaikymas (MP3, MP4, FLAC, OGG, OPUS ir MP4 failus) -- Išorinio ekvalaizerio funkcija (pvz., „Wavelet“) +- Visiškas ReplayGain palaikymas (MP3, MP4, FLAC, OGG, OPUS ir MP4 failus) +- Išorinio ekvalaizerio funkcija (pvz., Wavelet) - Krašto iki krašto - Įterptųjų viršelių palaikymas - Paieškos funkcija - Automatinis ausinių grojimas - Stilingi valdikliai, kurie automatiškai prisitaiko prie savo dydžio - Visiškai privatus ir neprisijungęs -- Jokių suapvalintų albumų viršelių (Nebent norite. Tada galite.) +- Jokių suapvalintų albumų viršelių (pagal numatytuosius nustatymus) diff --git a/fastlane/metadata/android/pa/full_description.txt b/fastlane/metadata/android/pa/full_description.txt index d4b927d7b..c896207aa 100644 --- a/fastlane/metadata/android/pa/full_description.txt +++ b/fastlane/metadata/android/pa/full_description.txt @@ -1,22 +1,23 @@ -Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱਕ ਸਥਾਨਕ ਸੰਗੀਤ ਪਲੇਅਰ ਹੈ ਜੋ ਦੂਜੇ ਸੰਗੀਤ ਪਲੇਅਰਾਂ ਵਿੱਚ ਮੌਜੂਦ ਬਹੁਤ ਸਾਰੀਆਂ ਬੇਕਾਰ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਤੋਂ ਬਿਨਾਂ ਹੈ। Exoplayer ਤੋਂ ਬਣਿਆ, Auxio ਕੋਲ ਪੁਰਾਣੀ ਐਂਡਰੌਇਡ ਕਾਰਜਕੁਸ਼ਲਤਾ ਦੀ ਵਰਤੋਂ ਕਰਨ ਵਾਲੀਆਂ ਹੋਰ ਐਪਾਂ ਦੇ ਮੁਕਾਬਲੇ ਵਧੀਆ ਲਾਇਬ੍ਰੇਰੀ ਸਹਾਇਤਾ ਅਤੇ ਸੁਣਨ ਦੀ ਗੁਣਵੱਤਾ ਹੈ। ਸੰਖੇਪ ਵਿੱਚ, ਇਹ ਸੰਗੀਤ ਚਲਾਉਂਦਾ ਹੈ. +Auxio ਇੱਕ ਤੇਜ਼, ਭਰੋਸੇਮੰਦ UI/UX ਵਾਲਾ ਇੱਕ ਸਥਾਨਕ ਸੰਗੀਤ ਪਲੇਅਰ ਹੈ ਜੋ ਦੂਜੇ ਸੰਗੀਤ ਪਲੇਅਰਾਂ ਵਿੱਚ ਮੌਜੂਦ ਬਹੁਤ ਸਾਰੀਆਂ ਬੇਕਾਰ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਤੋਂ ਬਿਨਾਂ ਹੈ। ਆਧੁਨਿਕ ਮੀਡੀਆ ਪਲੇਬੈਕ ਲਾਇਬ੍ਰੇਰੀਆਂ ਤੋਂ ਬਣਿਆ, Auxio ਕੋਲ ਪੁਰਾਣੀ ਐਂਡਰੌਇਡ ਕਾਰਜਕੁਸ਼ਲਤਾ ਦੀ ਵਰਤੋਂ ਕਰਨ ਵਾਲੀਆਂ ਹੋਰ ਐਪਾਂ ਦੇ ਮੁਕਾਬਲੇ ਵਧੀਆ ਲਾਇਬ੍ਰੇਰੀ ਸਹਾਇਤਾ ਅਤੇ ਸੁਣਨ ਦੀ ਗੁਣਵੱਤਾ ਹੈ। ਸੰਖੇਪ ਵਿੱਚ, ਇਹ ਸੰਗੀਤ ਚਲਾਉਂਦਾ ਹੈ. ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ -- ExoPlayer-ਅਧਾਰਿਤ ਪਲੇਬੈਕ -- ਨਵੀਨਤਮ ਸਮੱਗਰੀ ਡਿਜ਼ਾਈਨ ਦਿਸ਼ਾ-ਨਿਰਦੇਸ਼ਾਂ ਤੋਂ ਲਿਆ ਗਿਆ Snappy UI +- ਮੀਡੀਆ 3 ਐਕਸੋਪਲੇਅਰ ਅਧਾਰਿਤ ਪਲੇਬੈਕ +- ਨਵੀਨਤਮ ਸਮੱਗਰੀ ਡਿਜ਼ਾਈਨ ਦਿਸ਼ਾ-ਨਿਰਦੇਸ਼ਾਂ ਤੋਂ ਲਿਆ ਗਿਆ ਚੁਸਤ-ਦਰੁਸਤ UI - ਓਪੀਨੀਏਟਿਡ UX ਜੋ ਕਿ ਕਿਨਾਰੇ ਕੇਸਾਂ 'ਤੇ ਵਰਤੋਂ ਵਿੱਚ ਆਸਾਨੀ ਨੂੰ ਤਰਜੀਹ ਦਿੰਦਾ ਹੈ - ਅਨੁਕੂਲਿਤ ਵਿਵਹਾਰ - ਡਿਸਕ ਨੰਬਰਾਂ, ਮਲਟੀਪਲ ਕਲਾਕਾਰਾਂ, ਰੀਲੀਜ਼ ਕਿਸਮਾਂ, ਸਟੀਕ ਲਈ ਸਮਰਥਨ /ਮੂਲ ਤਾਰੀਖਾਂ, ਕ੍ਰਮਬੱਧ ਟੈਗਸ, ਅਤੇ ਹੋਰ - ਉੱਨਤ ਕਲਾਕਾਰ ਪ੍ਰਣਾਲੀ ਜੋ ਕਲਾਕਾਰਾਂ ਅਤੇ ਐਲਬਮ ਕਲਾਕਾਰਾਂ ਨੂੰ ਇਕਜੁੱਟ ਕਰਦੀ ਹੈ - SD ਕਾਰਡ-ਜਾਣੂ ਫੋਲਡਰ ਪ੍ਰਬੰਧਨ + - ਭਰੋਸੇਯੋਗ ਪਲੇਅਲਿਸਟਿੰਗ ਕਾਰਜਕੁਸ਼ਲਤਾ - ਭਰੋਸੇਯੋਗ ਪਲੇਅਬੈਕ ਸਥਿਤੀ ਸਥਿਰਤਾ -- ਪੂਰਾ ਰੀਪਲੇਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) +- ਪੂਰਾ ਰੀਪਲੇਅ-ਗੇਨ ਸਮਰਥਨ (MP3, FLAC, OGG, OPUS, ਅਤੇ MP4 ਫਾਈਲਾਂ 'ਤੇ) - ਬਾਹਰੀ ਈਕੋਲਾਈਜ਼ਰ ਦਾ ਸਮਰਥਨ (ਉਦਾਹਰਨ. ਵੇਵਲੇਟ) - ਕਿਨਾਰੇ-ਤੋਂ-ਕਿਨਾਰੇ -- ਏਮਬੈਡਡ ਕਵਰ ਸਪੋਰਟ +- ਏਮਬੈੱਡਡ ਕਵਰ ਸਪੋਰਟ - ਖੋਜ ਕਾਰਜਸ਼ੀਲਤਾ - ਹੈੱਡਸੈੱਟ ਆਟੋਪਲੇ - ਸਟਾਈਲਿਸ਼ ਵਿਜੇਟਸ ਜੋ ਆਪਣੇ ਆਪ ਉਹਨਾਂ ਦੇ ਆਕਾਰ ਦੇ ਅਨੁਕੂਲ ਬਣਦੇ ਹਨ -- ਪੂਰੀ ਤਰ੍ਹਾਂ ਨਿੱਜੀ ਅਤੇ ਔਫਲਾਈਨ -- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਜਦੋਂ ਤੱਕ ਤੁਸੀਂ ਉਹਨਾਂ ਨੂੰ ਨਹੀਂ ਚਾਹੁੰਦੇ ਹੋ। ਤੁਸੀਂ ਕਰ ਸੱਕਦੇ ਹੋ।) +- ਪੂਰੀ ਤਰ੍ਹਾਂ ਨਿੱਜੀ ਅਤੇ ਆਫਲਾਈਨ +- ਕੋਈ ਗੋਲ ਐਲਬਮ ਕਵਰ ਨਹੀਂ (ਡਿਫ਼ਾਲਟ ਤੌਰ ਤੇ) diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt new file mode 100644 index 000000000..65e70b61e --- /dev/null +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -0,0 +1,21 @@ +Auxio é um leitor de música local com uma UI/UX rápida e fiável sem as muitas funcionalidades inúteis presentes noutros leitores de música. Construído a partir de bibliotecas de reprodução de mídia modernas, Auxio tem suporte de biblioteca superior e qualidade de audição em comparação com outras aplicações que usam funcionalidade Android desatualizadas. Em suma, toca música. + +Caraterísticas + +- Reprodução baseada em Media3 ExoPlayer +- Snappy UI derivada das mais recentes diretrizes de Material Design +- UX opinativa que prioriza a facilidade de uso sobre casos de borda +- Comportamento personalizável +- Suporte para números de disco, vários artistas, tipos de lançamento, +datas precisas/originais, tags de classificação e muito mais +- Sistema avançado de artistas que unifica artistas e artistas de álbuns +- Gerenciamento de pastas com reconhecimento de cartão SD +- Funcionalidade de playlisting confiável +- Persistência do estado de reprodução +- Suporte completo ReplayGain (em arquivos MP3, FLAC, OGG, OPUS e MP4) +- Suporte de equalizador externo (ex. Wavelet) +- De ponta a ponta +- Suporte de capas embutidas +- Funcionalidade de pesquisa +- Reprodução automática de auscultadores +- Elegante diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt new file mode 100644 index 000000000..afb175103 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/short_description.txt @@ -0,0 +1 @@ +Um leitor de música simples diff --git a/fastlane/metadata/android/ru/full_description.txt b/fastlane/metadata/android/ru/full_description.txt index c58195709..634f1e263 100644 --- a/fastlane/metadata/android/ru/full_description.txt +++ b/fastlane/metadata/android/ru/full_description.txt @@ -19,4 +19,4 @@ Auxio — это локальный музыкальный плеер с быс - Автоматическое воспроизведение в наушниках - Адаптивные виджеты - Полностью частный и офлайн -- Никаких закруглённых обложек альбомов (если вы их не хотите) +- Никаких закруглённых обложек альбомов (по умолчанию) diff --git a/fastlane/metadata/android/sl/full_description.txt b/fastlane/metadata/android/sl/full_description.txt new file mode 100644 index 000000000..c8ee9819d --- /dev/null +++ b/fastlane/metadata/android/sl/full_description.txt @@ -0,0 +1,22 @@ +Auxio je lokalni predvajalnik glasbe z hitrim in zanesljivim uporabniškim vmesnikom brez večino nepotrebnih funkcij, ki jih najdete v drugih predvajalnikih glasbe. Zgrajen na sodobnih knjižnicah za predvajanje medijskih vsebin, Auxio ponuja izjemno podporo za knjižnico in kakovost poslušanja v primerjavi z aplikacijami, ki uporabljajo zastarelo funkcionalnost Androida. Skratka, predvaja glasbo. + +Lastnosti + +- Predvajanje temelji na Media3 ExoPlayer predvajalniku +- Hiter uporabniški vmesnik, izpeljan iz najnovejših smernic oblikovanja gradiva (Material Design) +- Samostojno premišljena uporabniška izkušnja, ki postavlja enostavnost uporabe pred izjemne primere, ki se zelo redko zgodijo +- Prilagodljivo obnašanje +- Podpora za številke diskov, več izvajalcev, vrste izdaj, natančne/izvirne datume, razvrščalne oznake in še več +- Napreden sistem izvajalcev, ki združuje izvajalce in izvajalce albumov +- Upravljanje map na SD kartici +- Zanesljiva funkcionalnost ustvarjanja seznama predvajanja +- Trajnost stanja predvajanja +- Popolna podpora za ReplayGain tehnologijo (za MP3, FLAC, OGG, OPUS in MP4 datoteke) +- Podpora za zunanje izenačevalnike (npr. Wavelet) +- Od roba do roba +- Podpora za vdelane naslovnice albumov +- Funkcionalnost iskanja +- Avtomatski zagon ob priključitvi slušalk +- Elegantni pripomočki, ki se samodejno prilagajajo svoji velikosti +- Popolnoma zasebno in brez povezave +- Brez zaobljenih naslovnic albumov (če jih ne želite; če pa želite, jih lahko omogočite) diff --git a/fastlane/metadata/android/sl/short_description.txt b/fastlane/metadata/android/sl/short_description.txt new file mode 100644 index 000000000..568819de9 --- /dev/null +++ b/fastlane/metadata/android/sl/short_description.txt @@ -0,0 +1 @@ +Preprost, racionalen predvajalnik glasbe diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt index e5b9134cf..86460ef08 100644 --- a/fastlane/metadata/android/tr/full_description.txt +++ b/fastlane/metadata/android/tr/full_description.txt @@ -1,19 +1,23 @@ -Auxio, diğer müzik oynatıcılarda bulunan birçok gereksiz özellik olmadan hızlı, güvenilir bir kullanıcı arayüzüne ve deneyimine sahip yerel bir müzik çalardır. <a href="https://exoplayer.dev/">Exoplayer</a> üzerine inşa edilen Auxio, yerel MediaPlayer API'sini kullanan diğer uygulamalara kıyasla çok daha iyi bir dinleme deneyimine sahiptir. Kısaca, Müzik çalar. +Auxio, diğer müzik çalarlarda bulunan birçok gereksiz özellik olmadan hızlı, güvenilir bir UI / UX'a sahip yerel bir müzik oynatıcıdır. Modern medya oynatma kütüphaneleri üzerine inşa edilen Auxio, eski android işlevselliğini kullanan diğer uygulamalara kıyasla üstün kütüphane desteği ve dinleme kalitesine sahiptir. Kısacası, Müzik çalar. Özellikler -- ExoPlayer tabanlı oynatma +- Media3 ExoPlayer tabanlı oynatma - En son Materyal Tasarım yönergelerinden türetilen hızlı kullanıcı arayüzü - Uç durumlardan ziyade kullanım kolaylığına öncelik veren fikir sahibi kullanıcı deneyimi - Özelleştirilebilir davranış -- Doğru meta verilere öncelik veren gelişmiş medya indeksleyici +- Disk numaraları, çoklu sanatçılar, sürüm türleri için destek, +kesin/orijinal tarihler, sıralama etiketleri ve daha fazlası +- Sanatçıları ve albüm sanatçılarını birleştiren gelişmiş sanatçı sistemi - SD Card-aware klasör yönetimi -- Güvenilir oynatma durumu kalıcılığı -- Tam ReplayGain desteği (MP3, MP4, FLAC, OGG ve OPUS'ta) +- Güvenilir çalma listesi işlevi +- Oynatma durumu kalıcılığı +- Tam ReplayGain desteği (MP3, FLAC, OGG, OPUS ve MP4 dosyalarında) +- Harici ekolayzer desteği (örn. Wavelet) - Kenardan kenara - Gömülü kapak desteği -- Arama İşlevselliği +- Arama işlevi - Kulaklık otomatik oynatma - Boyutlarına otomatik olarak uyum sağlayan şık widget'lar - Tamamen özel ve çevrimdışı -- Yuvarlak albüm kapakları yok (İstediğiniz zaman açıp kapatabilirsiniz.) +- Yuvarlak albüm kapakları yok (İstemediğiniz sürece. O zaman yapabilirsiniz.) diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 55851c380..890722b67 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -1,8 +1,8 @@ -Auxio – це локальний музичний плеєр зі швидким і надійним UI/UX без багатьох непотрібних функцій, наявних в інших музичних плеєрах. Створений на основі Exo Player, Auxio має кращу підтримку бібліотеки та якість прослуховування порівняно з іншими застосунками, які використовують застарілі функції Android. Одним словом, він відтворює музику. +Auxio – це локальний музичний плеєр зі швидким і надійним UI/UX без багатьох непотрібних функцій, наявних в інших музичних плеєрах. Створений на основі сучасних медіа-бібліотек відтворення, Auxio має кращу підтримку бібліотеки та якість прослуховування порівняно з іншими застосунками, які використовують застарілі функції Android. Одним словом, він відтворює музику. Особливості -- Відтворення на основі ExoPlayer +- Відтворення на основі Media 3 ExoPlayer - Швидкий UI створений на основі останніх рекомендацій Material Design - Продуманий UX, який надає перевагу простоті використання над крайнощами - Налаштовувана поведінка @@ -20,4 +20,4 @@ Auxio – це локальний музичний плеєр зі швидки - Автоматичне відтворення в навушниках - Стильні віджети, які автоматично підлаштовуються під розмір - Повністю приватний і офлайн -- Жодних заокруглених обкладинок альбомів (якщо ви їх не хочете) +- Жодних заокруглених обкладинок альбомів (за замовчуванням) diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index a1ca11be0..318031e5c 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -1,8 +1,8 @@ -Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于 Exoplayer 进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子。 +Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没有其他音乐播放器中的诸多无用功能。和其他使用原生 MediaPlayer API 的应用相比,Auxio 基于现代媒体播放库进行构建,聆听体验更佳,有更好的音乐库支持,正是一款音乐播放器应有的样子功能特性 -- 基于 ExoPlayer 的播放 +- 基于 Media3 ExoPlayer 的播放 - 源于最新 Material You 设计规范的灵动界面 - 优先考虑易用性的独到用户体验 - 可定制的播放器行为 @@ -20,4 +20,4 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 耳机连接时自动播放 - 按桌面尺寸自适应的风格化微件 - 完全离线且私密 -- 没有圆角的专辑封面(如果你想要也可以拥有) +- 没有圆角的专辑封面(默认设置) diff --git a/fastlane/metadata/android/zh-Hant/full_description.txt b/fastlane/metadata/android/zh-Hant/full_description.txt new file mode 100644 index 000000000..26596541a --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/full_description.txt @@ -0,0 +1,22 @@ +Auxio 是一款本機音樂播放器,擁有快速且可靠的 UI/UX,不含其他音樂播放器中許多無用的功能。Auxio 基於現代媒體播放庫構建,與使用過時 Android 功能的其他應用相比,擁有更優越的庫支援和聆聽品質。簡而言之,它播放音樂。 + +功能 + +- 基於 Media3 ExoPlayer 的播放功能 +- 源自最新 Material Design 指南的靈敏 UI +- 優化 UX,重視易用性高於邊緣情況 +- 可自訂的行為 +- 支援碟數、多位藝術家、發行類型、精確/原始日期、排序標籤等等 +- 進階藝術家系統,統一藝術家與專輯藝術家 +- 支援 SD 卡的資料夾管理 +- 可靠的播放列表功能 +- 播放狀態持久性 +- 完整的 ReplayGain 支援(適用於 MP3、FLAC、OGG、OPUS 和 MP4 檔案) +- 外部均衡器支援(例如 Wavelet) +- 無邊界設計 +- 內嵌封面支援 +- 搜尋功能 +- 耳機自動播放 +- 時尚的小工具,自動適應大小 +- 完全私密且離線 +- 默認不使用圓角專輯封面 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2c3425d49..880137945 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionSha256Sum=03ec176d388f2aa99defcadc3ac6adf8dd2bce5145a129659537c0874dea5ad1 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/media b/media index 8712967a7..2cfefb8f3 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 8712967a789192d60d2207451cd5ed2b3191999e +Subproject commit 2cfefb8f39d84412920d17be4ba76ebaabf2d6a6 diff --git a/settings.gradle b/settings.gradle index c8be53d93..61caa7aa7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,22 @@ -include ':app' -rootProject.name = "Auxio" +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + gradle.ext.androidxMediaModulePrefix = 'media-' gradle.ext.androidxMediaProjectName = 'media-' -apply from: file("media/core_settings.gradle") \ No newline at end of file +apply from: file("media/core_settings.gradle") + +rootProject.name = "Auxio" +include ':app' \ No newline at end of file