@@ -21,7 +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.**
+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.**
I primarily built Auxio for myself, but you can use it too, I guess.
@@ -42,7 +42,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
@@ -69,12 +69,11 @@ precise/original dates, sort tags, and more
## 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 a6c58436a..6ab20ef92 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 {
@@ -20,8 +21,8 @@ android {
defaultConfig {
applicationId namespace
- versionName "3.1.4"
- versionCode 34
+ versionName "3.2.0"
+ versionCode 35
minSdk 24
targetSdk 34
@@ -77,7 +78,7 @@ 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"
@@ -87,7 +88,7 @@ dependencies {
implementation "androidx.core:core-ktx:1.10.1"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.activity:activity-ktx:1.7.2"
- implementation "androidx.fragment:fragment-ktx:1.6.0"
+ implementation "androidx.fragment:fragment-ktx:1.6.1"
// Components
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
@@ -113,12 +114,12 @@ dependencies {
implementation "androidx.media:media:1.6.0"
// Preferences
- implementation "androidx.preference:preference-ktx:1.2.0"
+ implementation "androidx.preference:preference-ktx:1.2.1"
// Database
- def room_version = '2.6.0-alpha01'
+ def room_version = '2.6.0-alpha03'
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 ---
@@ -133,7 +134,7 @@ dependencies {
// Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
- implementation "com.google.android.material:material:1.10.0-alpha04"
+ implementation "com.google.android.material:material:1.10.0-alpha06"
// Dependency Injection
implementation "com.google.dagger:dagger:$hilt_version"
@@ -142,7 +143,7 @@ dependencies {
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Testing
- debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java
index 214f6ac62..577036ec4 100644
--- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java
+++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java
@@ -1737,16 +1737,10 @@ 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() {
@@ -1758,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);
@@ -1767,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;
}
@@ -1810,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/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/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index ecc8f21f5..297bad95c 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -26,10 +26,7 @@ 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
@@ -41,16 +38,17 @@ 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.MusicViewModel
-import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.navigation.MainNavigationAction
-import org.oxycblt.auxio.navigation.NavigationViewModel
+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
@@ -64,30 +62,23 @@ 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)
- *
- * TODO: Break up the god navigation setup going on here
*/
@AndroidEntryPoint
class MainFragment :
- ViewBindingFragment(),
- ViewTreeObserver.OnPreDrawListener,
- NavController.OnDestinationChangedListener {
- private val navModel: NavigationViewModel by activityViewModels()
- private val musicModel: MusicViewModel by activityViewModels()
- 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)
@@ -110,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
@@ -148,11 +130,7 @@ class MainFragment :
// In portrait mode, set up click listeners on the stacked sheets.
logD("Configuring stacked bottom sheets")
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
- if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
- queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
- // Playback sheet is expanded and queue sheet is collapsed, we can expand it.
- queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
- }
+ playbackModel.openQueue()
}
} else {
// Dual-pane mode, manually style the static queue sheet.
@@ -173,18 +151,16 @@ 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)
- collect(musicModel.newPlaylistSongs.flow, ::handleNewPlaylist)
- collect(musicModel.playlistToRename.flow, ::handleRenamePlaylist)
- collect(musicModel.playlistToDelete.flow, ::handleDeletePlaylist)
- collect(musicModel.songsToAdd.flow, ::handleAddToPlaylist)
- collect(navModel.mainNavigationAction.flow, ::handleMainNavigation)
- collect(navModel.exploreNavigationItem.flow, ::handleExploreNavigation)
- collect(navModel.exploreArtistNavigationItem.flow, ::handleArtistNavigationPicker)
+ collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
+ collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong)
- collect(playbackModel.artistPickerSong.flow, ::handlePlaybackArtistPicker)
- collect(playbackModel.genrePickerSong.flow, ::handlePlaybackGenrePicker)
+ collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
}
override fun onStart() {
@@ -192,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)
}
@@ -211,7 +200,7 @@ class MainFragment :
sheetBackCallback = null
detailBackCallback = null
selectionBackCallback = null
- exploreBackCallback = null
+ selectionNavigationListener = null
}
override fun onPreDraw(): Boolean {
@@ -305,48 +294,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 handleMainNavigation(action: MainNavigationAction?) {
- if (action != null) {
- when (action) {
- is MainNavigationAction.OpenPlaybackPanel -> tryOpenPlaybackPanel()
- is MainNavigationAction.ClosePlaybackPanel -> tryClosePlaybackPanel()
- is MainNavigationAction.Directions ->
- findNavController().navigateSafe(action.directions)
+ private fun handleShowOuter(outer: Outer?) {
+ val directions =
+ when (outer) {
+ is Outer.Settings -> MainFragmentDirections.preferences()
+ is Outer.About -> MainFragmentDirections.about()
+ null -> return
}
- navModel.mainNavigationAction.consume()
- }
- }
-
- private fun handleExploreNavigation(item: Music?) {
- if (item != null) {
- tryClosePlaybackPanel()
- }
- }
-
- private fun handleArtistNavigationPicker(item: Music?) {
- if (item != null) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.actionPickNavigationArtist(item.uid)))
- navModel.exploreArtistNavigationItem.consume()
- }
+ findNavController().navigateSafe(directions)
+ homeModel.showOuter.consume()
}
private fun updateSong(song: Song?) {
@@ -357,56 +327,15 @@ class MainFragment :
}
}
- private fun handleNewPlaylist(songs: List?) {
- if (songs != null) {
- findNavController()
- .navigateSafe(
- MainFragmentDirections.actionNewPlaylist(songs.map { it.uid }.toTypedArray()))
- musicModel.newPlaylistSongs.consume()
- }
- }
-
- private fun handleRenamePlaylist(playlist: Playlist?) {
- if (playlist != null) {
- findNavController()
- .navigateSafe(MainFragmentDirections.actionRenamePlaylist(playlist.uid))
- musicModel.playlistToRename.consume()
- }
- }
-
- private fun handleDeletePlaylist(playlist: Playlist?) {
- if (playlist != null) {
- findNavController()
- .navigateSafe(MainFragmentDirections.actionDeletePlaylist(playlist.uid))
- musicModel.playlistToDelete.consume()
- }
- }
-
- private fun handleAddToPlaylist(songs: List?) {
- if (songs != null) {
- findNavController()
- .navigateSafe(
- MainFragmentDirections.actionAddToPlaylist(songs.map { it.uid }.toTypedArray()))
- musicModel.songsToAdd.consume()
- }
- }
-
- private fun handlePlaybackArtistPicker(song: Song?) {
- if (song != null) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.actionPickPlaybackArtist(song.uid)))
- playbackModel.artistPickerSong.consume()
- }
- }
-
- private fun handlePlaybackGenrePicker(song: Song?) {
- if (song != null) {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
- playbackModel.genrePickerSong.consume()
+ private fun handlePanel(panel: OpenPanel?) {
+ if (panel == null) return
+ logD("Trying to update panel to $panel")
+ when (panel) {
+ OpenPanel.MAIN -> tryClosePlaybackPanel()
+ OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
+ OpenPanel.QUEUE -> tryOpenQueuePanel()
}
+ playbackModel.openPanel.consume()
}
private fun tryOpenPlaybackPanel() {
@@ -446,6 +375,19 @@ class MainFragment :
}
}
+ private fun tryOpenQueuePanel() {
+ val binding = requireBinding()
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
+ val queueSheetBehavior =
+ (binding.queueSheet.coordinatorLayoutBehavior ?: return) as QueueBottomSheetBehavior
+ if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
+ queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
+ // Playback sheet is expanded and queue sheet is collapsed, we can expand it.
+ queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+
private fun tryShowSheets() {
val binding = requireBinding()
val playbackSheetBehavior =
@@ -535,11 +477,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")
}
}
@@ -548,23 +489,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 d32f5254b..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,27 +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.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.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
-import org.oxycblt.auxio.navigation.NavigationViewModel
+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
/**
@@ -73,10 +68,10 @@ class AlbumDetailFragment :
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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()
+
// 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()
@@ -103,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
@@ -124,11 +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)
+ collect(listModel.menu.flow, ::handleMenu)
+ collectImmediately(listModel.selected, ::updateSelection)
+ collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
- collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
- collectImmediately(selectionModel.selected, ::updateSelection)
+ collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@@ -137,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() {
@@ -193,35 +156,12 @@ 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() {
- navModel.exploreNavigateToParentArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
+ detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
private fun updateAlbum(album: Album?) {
@@ -234,56 +174,135 @@ class AlbumDetailFragment :
albumHeaderAdapter.setParent(album)
}
- private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
- albumListAdapter.setPlaying(
- song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
+ private fun updateList(list: List- ) {
+ albumListAdapter.update(list, detailModel.albumSongInstructions.consume())
}
- private fun handleNavigation(item: Music?) {
+ private fun handleShow(show: Show?) {
val binding = requireBinding()
- when (item) {
+ when (show) {
+ is Show.SongDetails -> {
+ logD("Navigating to ${show.song}")
+ findNavController()
+ .navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
+ }
+
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
- is Song -> {
- if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) {
- logD("Navigating to a song in this album")
- scrollToAlbumSong(item)
- navModel.exploreNavigationItem.consume()
+ is Show.SongAlbumDetails -> {
+ if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
+ logD("Navigating to a ${show.song} in this album")
+ scrollToAlbumSong(show.song)
+ detailModel.toShow.consume()
} else {
- logD("Navigating to another album")
+ logD("Navigating to the album of ${show.song}")
findNavController()
- .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid))
+ .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
}
}
// If the album matches, no need to do anything. Otherwise launch a new
// detail fragment.
- is Album -> {
- if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) {
+ is Show.AlbumDetails -> {
+ if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
- navModel.exploreNavigationItem.consume()
+ detailModel.toShow.consume()
} else {
- logD("Navigating to another album")
+ logD("Navigating to ${show.album}")
findNavController()
- .navigateSafe(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
+ .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
}
}
-
- // Always launch a new ArtistDetailFragment.
- is Artist -> {
- logD("Navigating to another artist")
+ is Show.ArtistDetails -> {
+ logD("Navigating to ${show.artist}")
+ findNavController()
+ .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
+ }
+ is Show.SongArtistDecision -> {
+ logD("Navigating to artist choices for ${show.song}")
findNavController()
- .navigateSafe(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
+ .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
+ }
+ is Show.AlbumArtistDecision -> {
+ logD("Navigating to artist choices for ${show.album}")
+ findNavController()
+ .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
+ }
+ is Show.GenreDetails,
+ is Show.PlaylistDetails -> {
+ error("Unexpected show command $show")
}
null -> {}
- else -> error("Unexpected datatype: ${item::class.java}")
}
}
+ 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())
+
+ val binding = requireBinding()
+ if (selected.isNotEmpty()) {
+ binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
+ binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
+ } else {
+ binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
+ }
+ }
+
+ 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")
+ }
+ findNavController().navigateSafe(directions)
+ }
+
+ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
+ albumListAdapter.setPlaying(
+ song.takeIf { parent == detailModel.currentAlbum.value }, 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")
+ 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.
@@ -318,20 +337,4 @@ class AlbumDetailFragment :
}
}
}
-
- private fun updateList(list: List
- ) {
- albumListAdapter.update(list, detailModel.albumInstructions.consume())
- }
-
- private fun updateSelection(selected: List) {
- albumListAdapter.setSelected(selected.toSet())
-
- val binding = requireBinding()
- if (selected.isNotEmpty()) {
- binding.detailSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
- binding.detailToolbar.setVisible(R.id.detail_selection_toolbar)
- } else {
- binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
- }
- }
}
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 601c2ed50..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,24 +37,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.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
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.navigation.NavigationViewModel
+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
/**
@@ -70,10 +67,9 @@ class ArtistDetailFragment :
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
@@ -100,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 {
@@ -110,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
@@ -124,11 +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)
+ collect(listModel.menu.flow, ::handleMenu)
+ collectImmediately(listModel.selected, ::updateSelection)
+ collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
- collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
- collectImmediately(selectionModel.selected, ::updateSelection)
+ collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@@ -137,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 -> navModel.exploreNavigateTo(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 Album -> detailModel.showAlbum(item)
+ 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}")
}
}
@@ -206,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?) {
@@ -241,74 +176,82 @@ 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 updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
- val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
- val playingItem =
- when (parent) {
- // Always highlight a playing album if it's from this artist, and if the currently
- // playing song is contained within.
- is Album -> parent.takeIf { song?.album == it }
- // If the parent is the artist itself, use the currently playing song.
- currentArtist -> song
- // Nothing is playing from this artist.
- else -> null
- }
- artistListAdapter.setPlaying(playingItem, isPlaying)
+ private fun updateList(list: List
- ) {
+ artistListAdapter.update(list, detailModel.artistSongInstructions.consume())
}
- private fun handleNavigation(item: Music?) {
+ private fun handleShow(show: Show?) {
val binding = requireBinding()
+ when (show) {
+ is Show.SongDetails -> {
+ logD("Navigating to ${show.song}")
+ findNavController()
+ .navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
+ }
- when (item) {
// Songs should be shown in their album, not in their artist.
- is Song -> {
- logD("Navigating to another album")
+ is Show.SongAlbumDetails -> {
+ logD("Navigating to the album of ${show.song}")
findNavController()
- .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid))
+ .navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
}
+
// Launch a new detail view for an album, even if it is part of
// this artist.
- is Album -> {
- logD("Navigating to another album")
+ is Show.AlbumDetails -> {
+ logD("Navigating to ${show.album}")
findNavController()
- .navigateSafe(ArtistDetailFragmentDirections.actionShowAlbum(item.uid))
+ .navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
}
+
// If the artist that should be navigated to is this artist, then
// scroll back to the top. Otherwise launch a new detail view.
- is Artist -> {
- if (item.uid == detailModel.currentArtist.value?.uid) {
+ is Show.ArtistDetails -> {
+ if (show.artist == detailModel.currentArtist.value) {
logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
- navModel.exploreNavigationItem.consume()
+ detailModel.toShow.consume()
} else {
- logD("Navigating to another artist")
+ logD("Navigating to ${show.artist}")
findNavController()
- .navigateSafe(ArtistDetailFragmentDirections.actionShowArtist(item.uid))
+ .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
}
}
+ 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")
+ }
null -> {}
- else -> error("Unexpected datatype: ${item::class.java}")
}
}
- private fun updateList(list: List
- ) {
- artistListAdapter.update(list, detailModel.artistInstructions.consume())
+ 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) {
@@ -322,4 +265,49 @@ class ArtistDetailFragment :
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
+
+ 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")
+ }
+ findNavController().navigateSafe(directions)
+ }
+
+ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
+ val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
+ val playingItem =
+ when (parent) {
+ // Always highlight a playing album if it's from this artist, and if the currently
+ // playing song is contained within.
+ is Album -> parent.takeIf { song?.album == it }
+ // If the parent is the artist itself, use the currently playing song.
+ currentArtist -> song
+ // Nothing is playing from this artist.
+ else -> null
+ }
+ artistListAdapter.setPlaying(playingItem, isPlaying)
+ }
+
+ 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 c63c76151..648424458 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,11 +66,18 @@ 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
+
// --- SONG ---
private var currentSongJob: Job? = null
@@ -90,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 ---
@@ -115,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
- /** The current [Sort] used for [Song]s in [artistList]. */
+ private val _artistSongInstructions = MutableEvent()
+ /** Instructions for updating [artistSongList] in the UI. */
+ val artistSongInstructions: Event
+ get() = _artistSongInstructions
+
+ /** 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)
@@ -139,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)
@@ -163,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)
/**
@@ -179,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)
@@ -237,6 +255,85 @@ 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.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.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) {
+ val existing = toShow.flow.value
+ if (existing != null) {
+ logD("Already have pending show command $existing, ignoring $show")
+ return
+ }
+ _toShow.put(show)
+ }
+
/**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* be updated to align with the new [Song].
@@ -252,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.
@@ -267,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.
@@ -282,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.
@@ -296,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.
@@ -353,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.
*
@@ -361,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
@@ -445,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) {
@@ -508,8 +645,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) {
@@ -534,8 +671,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(
@@ -554,8 +691,8 @@ constructor(
}
logD("Updating playlist list to ${list.size} items with $instructions")
- _playlistInstructions.put(instructions)
- _playlistList.value = list
+ _playlistSongInstructions.put(instructions)
+ _playlistSongList.value = list
}
/**
@@ -582,3 +719,69 @@ constructor(
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
+
+/**
+ * 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
+
+ /**
+ * 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 3968c1379..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,25 +37,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.Sort
-import org.oxycblt.auxio.list.selection.SelectionViewModel
-import org.oxycblt.auxio.music.Album
+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
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.navigation.NavigationViewModel
+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
/**
@@ -71,10 +67,9 @@ class GenreDetailFragment :
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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()
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs()
@@ -99,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 {
@@ -109,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
@@ -123,11 +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)
+ collect(listModel.menu.flow, ::handleMenu)
+ collectImmediately(listModel.selected, ::updateSelection)
+ collect(musicModel.playlistDecision.flow, ::handleDecision)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
- collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
- collectImmediately(selectionModel.selected, ::updateSelection)
+ collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@@ -136,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 -> navModel.exploreNavigateTo(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 Artist -> detailModel.showArtist(item)
+ 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}")
}
}
@@ -205,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?) {
@@ -242,47 +178,73 @@ class GenreDetailFragment :
genreHeaderAdapter.setParent(genre)
}
- private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
- val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
- val playingItem =
- when (parent) {
- // Always highlight a playing artist if it's from this genre, and if the currently
- // playing song is contained within.
- is Artist -> parent.takeIf { song?.run { artists.contains(it) } ?: false }
- // If the parent is the artist itself, use the currently playing song.
- currentGenre -> song
- // Nothing is playing from this artist.
- else -> null
- }
- genreListAdapter.setPlaying(playingItem, isPlaying)
+ private fun updateList(list: List- ) {
+ genreListAdapter.update(list, detailModel.genreSongInstructions.consume())
}
- private fun handleNavigation(item: Music?) {
- when (item) {
- is Song -> {
- logD("Navigating to another song")
+ private fun handleShow(show: Show?) {
+ when (show) {
+ is Show.SongDetails -> {
+ logD("Navigating to ${show.song}")
findNavController()
- .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
+ .navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
}
- is Album -> {
- logD("Navigating to another album")
+
+ // 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(GenreDetailFragmentDirections.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(GenreDetailFragmentDirections.showAlbum(show.album.uid))
+ }
+
+ // Always launch a new ArtistDetailFragment.
+ is Show.ArtistDetails -> {
+ logD("Navigating to ${show.artist}")
+ findNavController()
+ .navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
+ }
+ is Show.SongArtistDecision -> {
+ logD("Navigating to artist choices for ${show.song}")
findNavController()
- .navigateSafe(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
+ .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
}
- is Artist -> {
- logD("Navigating to another artist")
+ is Show.AlbumArtistDecision -> {
+ logD("Navigating to artist choices for ${show.album}")
findNavController()
- .navigateSafe(GenreDetailFragmentDirections.actionShowArtist(item.uid))
+ .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
}
- is Genre -> {
- navModel.exploreNavigationItem.consume()
+ is Show.GenreDetails -> {
+ logD("Navigated to this genre")
+ detailModel.toShow.consume()
}
- else -> {}
+ is Show.PlaylistDetails -> {
+ error("Unexpected show command $show")
+ }
+ null -> {}
}
}
- private fun updateList(list: List
- ) {
- genreListAdapter.update(list, detailModel.genreInstructions.consume())
+ 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) {
@@ -296,4 +258,48 @@ class GenreDetailFragment :
binding.detailToolbar.setVisible(R.id.detail_normal_toolbar)
}
}
+
+ private fun handleDecision(decision: PlaylistDecision?) {
+ 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")
+ }
+ findNavController().navigateSafe(directions)
+ }
+
+ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
+ val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
+ val playingItem =
+ when (parent) {
+ // Always highlight a playing artist if it's from this genre, and if the currently
+ // playing song is contained within.
+ is Artist -> parent.takeIf { song?.run { artists.contains(it) } ?: false }
+ // If the parent is the artist itself, use the currently playing song.
+ currentGenre -> song
+ // Nothing is playing from this artist.
+ else -> null
+ }
+ genreListAdapter.setPlaying(playingItem, 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")
+ 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 7cdf9443c..ed460bc33 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,24 +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.music.Album
-import org.oxycblt.auxio.music.Artist
+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.navigation.NavigationViewModel
+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
/**
@@ -72,20 +68,18 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
class PlaylistDetailFragment :
ListFragment(),
DetailHeaderAdapter.Listener,
- PlaylistDetailListAdapter.Listener,
- NavController.OnDestinationChangedListener {
+ PlaylistDetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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()
// Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs()
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)
@@ -103,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 {
@@ -124,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
@@ -138,25 +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.editedPlaylist, ::updateEditedPlaylist)
+ collectImmediately(detailModel.playlistSongList, ::updateList)
+ collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
+ collect(detailModel.toShow.flow, ::handleShow)
+ collect(listModel.menu.flow, ::handleMenu)
+ collectImmediately(listModel.selected, ::updateSelection)
+ collect(musicModel.playlistDecision.flow, ::handleDecision)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
- collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
- collectImmediately(selectionModel.selected, ::updateSelection)
+ 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,60 +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 updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
- // Prefer songs that are playing from this playlist.
- playlistListAdapter.setPlaying(
- song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying)
- }
-
- private fun handleNavigation(item: Music?) {
- when (item) {
- is Song -> {
- logD("Navigating to another song")
- findNavController()
- .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.album.uid))
- }
- is Album -> {
- logD("Navigating to another album")
- findNavController()
- .navigateSafe(PlaylistDetailFragmentDirections.actionShowAlbum(item.uid))
- }
- is Artist -> {
- logD("Navigating to another artist")
- findNavController()
- .navigateSafe(PlaylistDetailFragmentDirections.actionShowArtist(item.uid))
- }
- is Playlist -> {
- navModel.exploreNavigationItem.consume()
- }
- else -> {}
- }
- }
-
private fun updateList(list: List
- ) {
- playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
+ playlistListAdapter.update(list, detailModel.playlistSongInstructions.consume())
}
- private fun updateEditedPlaylist(editedPlaylist: List?) {
+ private fun updateEditedList(editedPlaylist: List?) {
playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
- selectionModel.drop()
+ listModel.dropSelection()
if (editedPlaylist != null) {
logD("Updating save button state")
@@ -324,6 +246,66 @@ class PlaylistDetailFragment :
updateMultiToolbar()
}
+ private fun handleShow(show: Show?) {
+ when (show) {
+ is Show.SongDetails -> {
+ logD("Navigating to ${show.song}")
+ findNavController()
+ .navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
+ }
+ is Show.SongAlbumDetails -> {
+ logD("Navigating to the album of ${show.song}")
+ findNavController()
+ .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
+ }
+ is Show.AlbumDetails -> {
+ logD("Navigating to ${show.album}")
+ findNavController()
+ .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
+ }
+ is Show.ArtistDetails -> {
+ logD("Navigating to ${show.artist}")
+ findNavController()
+ .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
+ }
+ is Show.SongArtistDecision -> {
+ logD("Navigating to artist choices for ${show.song}")
+ findNavController()
+ .navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
+ }
+ is Show.AlbumArtistDecision -> {
+ logD("Navigating to artist choices for ${show.album}")
+ findNavController()
+ .navigateSafe(
+ PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
+ }
+ is Show.PlaylistDetails -> {
+ logD("Navigated to this playlist")
+ detailModel.toShow.consume()
+ }
+ is Show.GenreDetails -> {
+ error("Unexpected show command $show")
+ }
+ null -> {}
+ }
+ }
+
+ 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())
@@ -334,6 +316,46 @@ class PlaylistDetailFragment :
updateMultiToolbar()
}
+ private fun handleDecision(decision: PlaylistDecision?) {
+ if (decision == null) return
+ 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,
+ is PlaylistDecision.New -> error("Unexpected playlist decision $decision")
+ }
+ findNavController().navigateSafe(directions)
+ }
+
+ private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
+ // Prefer songs that are playing from this playlist.
+ playlistListAdapter.setPlaying(
+ song.takeIf { parent == detailModel.currentPlaylist.value }, 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")
+ 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 updateMultiToolbar() {
val id =
when {
@@ -341,7 +363,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 ca38c061e..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,6 +69,7 @@ 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)
}
diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt
similarity index 91%
rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt
rename to app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt
index a397ec23b..98a411a04 100644
--- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/ArtistNavigationChoiceAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/decision/ArtistShowChoice.kt
@@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
- * ArtistNavigationChoiceAdapter.kt is part of Auxio.
+ * ArtistShowChoice.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.navigation.picker
+package org.oxycblt.auxio.detail.decision
import android.view.View
import android.view.ViewGroup
@@ -31,11 +31,11 @@ import org.oxycblt.auxio.util.inflater
/**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with
- * [NavigateToArtistDialog].
+ * [ShowArtistDialog].
*
* @param listener A [ClickableListListener] to bind interactions to.
*/
-class ArtistNavigationChoiceAdapter(private val listener: ClickableListListener) :
+class ArtistShowChoice(private val listener: ClickableListListener) :
FlexibleListAdapter(
ArtistNavigationChoiceViewHolder.DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
@@ -48,7 +48,7 @@ class ArtistNavigationChoiceAdapter(private val listener: ClickableListListener<
/**
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for
- * use [ArtistNavigationChoiceAdapter]. Use [from] to create an instance.
+ * use [ArtistShowChoice]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
diff --git a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/DetailDecisionViewModel.kt
similarity index 63%
rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigationPickerViewModel.kt
rename to app/src/main/java/org/oxycblt/auxio/detail/decision/DetailDecisionViewModel.kt
index f02621d5b..efe219235 100644
--- a/app/src/main/java/org/oxycblt/auxio/navigation/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.navigation.picker
+package org.oxycblt.auxio.detail.decision
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -28,49 +28,41 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.device.DeviceLibrary
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)
+ private val _artistChoices = MutableStateFlow(null)
/** The current set of [Artist] choices to show in the picker, or null if to show nothing. */
- val artistChoices: StateFlow
+ val artistChoices: StateFlow
get() = _artistChoices
init {
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
// Need to sanitize different items depending on the current set of choices.
- _artistChoices.value =
- when (val choices = _artistChoices.value) {
- is SongArtistNavigationChoices ->
- deviceLibrary.findSong(choices.song.uid)?.let {
- SongArtistNavigationChoices(it)
- }
- is AlbumArtistNavigationChoices ->
- deviceLibrary.findAlbum(choices.album.uid)?.let {
- AlbumArtistNavigationChoices(it)
- }
- else -> null
- }
+ _artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
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.
*
@@ -83,14 +75,14 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
when (val music = musicRepository.find(itemUid)) {
is Song -> {
logD("Creating navigation choices for song")
- SongArtistNavigationChoices(music)
+ ArtistShowChoices.FromSong(music)
}
is Album -> {
logD("Creating navigation choices for album")
- AlbumArtistNavigationChoices(music)
+ ArtistShowChoices.FromAlbum(music)
}
else -> {
- logD("Given song/album UID was invalid")
+ logW("Given song/album UID was invalid")
null
}
}
@@ -102,20 +94,29 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
*
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed interface ArtistNavigationChoices {
+sealed interface ArtistShowChoices {
+ /** The UID of the item. */
+ val uid: Music.UID
/** The current [Artist] choices. */
val choices: List
-}
+ /** Sanitize this instance with a [DeviceLibrary]. */
+ fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
-/** Backing implementation of [ArtistNavigationChoices] that is based on a [Song]. */
-private data class SongArtistNavigationChoices(val song: Song) : ArtistNavigationChoices {
- override val choices = song.artists
-}
+ /** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
+ class FromSong(val song: Song) : ArtistShowChoices {
+ override val uid = song.uid
+ override val choices = song.artists
-/**
- * Backing implementation of [ArtistNavigationChoices] that is based on an
- * [AlbumArtistNavigationChoices].
- */
-private data class AlbumArtistNavigationChoices(val album: Album) : ArtistNavigationChoices {
- override val choices = album.artists
+ override fun sanitize(newLibrary: DeviceLibrary) =
+ newLibrary.findSong(uid)?.let { FromSong(it) }
+ }
+
+ /** 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/navigation/picker/NavigateToArtistDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt
similarity index 69%
rename from app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt
rename to app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt
index ade74f930..1f82ddfe2 100644
--- a/app/src/main/java/org/oxycblt/auxio/navigation/picker/NavigateToArtistDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/decision/ShowArtistDialog.kt
@@ -1,6 +1,6 @@
/*
* Copyright (c) 2022 Auxio Project
- * NavigateToArtistDialog.kt is part of Auxio.
+ * ShowArtistDialog.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.navigation.picker
+package org.oxycblt.auxio.detail.decision
import android.os.Bundle
import android.view.LayoutInflater
@@ -29,27 +29,28 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
+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.navigation.NavigationViewModel
-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 [Artist] navigation is ambiguous.
+ * A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
-class NavigateToArtistDialog :
- ViewBindingDialogFragment(), ClickableListListener {
- private val navigationModel: NavigationViewModel by activityViewModels()
- private val pickerModel: NavigationPickerViewModel by viewModels()
+class ShowArtistDialog :
+ ViewBindingMaterialDialogFragment(), ClickableListListener {
+ private val detailModel: DetailViewModel by activityViewModels()
+ 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: NavigateToArtistDialogArgs by navArgs()
- private val choiceAdapter = ArtistNavigationChoiceAdapter(this)
+ private val args: ShowArtistDialogArgs by navArgs()
+ private val choiceAdapter = ArtistShowChoice(this)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null)
@@ -66,14 +67,9 @@ class NavigateToArtistDialog :
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 NavigateToArtistDialog :
}
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
- // User made a choice, navigate to the artist.
- navigationModel.exploreNavigateTo(item)
findNavController().navigateUp()
+ // User made a choice, navigate to the artist.
+ detailModel.showArtist(item)
+ }
+
+ 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..cb2343219 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)
}
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/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index f39a54e1f..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
@@ -43,9 +42,10 @@ import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
+import org.oxycblt.auxio.detail.DetailViewModel
+import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.home.list.AlbumListFragment
import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
@@ -53,24 +53,19 @@ 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.music.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Genre
+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
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
-import org.oxycblt.auxio.music.Playlist
+import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.navigation.MainNavigationAction
-import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
@@ -79,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
@@ -90,11 +84,11 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
@AndroidEntryPoint
class HomeFragment :
SelectionFragment(), AppBarLayout.OnOffsetChangedListener {
- override val playbackModel: PlaybackViewModel by activityViewModels()
- 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()
- private val navModel: NavigationViewModel by activityViewModels()
+ private val detailModel: DetailViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -104,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)
}
}
}
@@ -174,17 +168,19 @@ class HomeFragment :
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
- collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
- collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
+ collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
+ collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
+ collect(listModel.menu.flow, ::handleMenu)
+ collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
- collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
- collectImmediately(selectionModel.selected, ::updateSelection)
+ 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)
@@ -217,69 +213,48 @@ class HomeFragment :
// Handle main actions (Search, Settings, About)
R.id.action_search -> {
logD("Navigating to search")
- setupAxisTransitions(MaterialSharedAxis.Z)
- findNavController().navigateSafe(HomeFragmentDirections.actionShowSearch())
+ applyAxisTransition(MaterialSharedAxis.Z)
+ findNavController().navigateSafe(HomeFragmentDirections.search())
true
}
R.id.action_settings -> {
- logD("Navigating to settings")
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
+ logD("Navigating to preferences")
+ homeModel.showSettings()
true
}
R.id.action_about -> {
logD("Navigating to about")
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
+ 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")
@@ -297,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()
@@ -410,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()
@@ -422,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) {
@@ -437,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))
+ }
+ }
}
}
}
@@ -465,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 -> {
@@ -486,6 +414,31 @@ class HomeFragment :
}
}
+ private fun handleDecision(decision: PlaylistDecision?) {
+ if (decision == null) return
+ 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())
+ }
+ }
+ findNavController().navigateSafe(directions)
+ }
+
private fun updateFab(songs: List, isFastScrolling: Boolean) {
val binding = requireBinding()
// If there are no songs, it's likely that the library has not been loaded, so
@@ -500,19 +453,65 @@ class HomeFragment :
}
}
- private fun handleNavigation(item: Music?) {
- val action =
- when (item) {
- is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid)
- is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
- is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
- is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
- is Playlist -> HomeFragmentDirections.actionShowPlaylist(item.uid)
- null -> return
+ private fun handleShow(show: Show?) {
+ when (show) {
+ is Show.SongDetails -> {
+ logD("Navigating to ${show.song}")
+ findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
+ }
+ is Show.SongAlbumDetails -> {
+ logD("Navigating to the album of ${show.song}")
+ applyAxisTransition(MaterialSharedAxis.X)
+ findNavController()
+ .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
}
+ is Show.AlbumDetails -> {
+ logD("Navigating to ${show.album}")
+ applyAxisTransition(MaterialSharedAxis.X)
+ findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
+ }
+ is Show.ArtistDetails -> {
+ logD("Navigating to ${show.artist}")
+ applyAxisTransition(MaterialSharedAxis.X)
+ findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
+ }
+ is Show.SongArtistDecision -> {
+ logD("Navigating to artist choices for ${show.song}")
+ findNavController()
+ .navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
+ }
+ is Show.AlbumArtistDecision -> {
+ logD("Navigating to artist choices for ${show.album}")
+ 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))
+ }
+ null -> {}
+ }
+ }
- setupAxisTransitions(MaterialSharedAxis.X)
- findNavController().navigateSafe(action)
+ 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) {
@@ -529,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"
@@ -550,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 3495bc85a..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,28 +21,26 @@ 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.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
-import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
@@ -59,10 +57,10 @@ class AlbumListFragment :
FastScrollRecyclerView.Listener,
FastScrollRecyclerView.PopupProvider {
private val homeModel: HomeViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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
@@ -138,15 +136,15 @@ class AlbumListFragment :
}
override fun onRealClick(item: Album) {
- navModel.exploreNavigateTo(item)
+ 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 e270fa7d2..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,31 +20,29 @@ 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
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.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.navigation.NavigationViewModel
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 navModel: NavigationViewModel 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
@@ -114,15 +112,15 @@ class ArtistListFragment :
}
override fun onRealClick(item: Artist) {
- navModel.exploreNavigateTo(item)
+ 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 ee9544d55..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,27 +20,25 @@ 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
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.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
-import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
@@ -56,10 +54,10 @@ class GenreListFragment :
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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
@@ -113,15 +111,15 @@ class GenreListFragment :
}
override fun onRealClick(item: Genre) {
- navModel.exploreNavigateTo(item)
+ 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 61fa54b7c..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,26 +20,24 @@ 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
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.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
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
@@ -54,10 +52,10 @@ class PlaylistListFragment :
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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
@@ -111,15 +109,15 @@ class PlaylistListFragment :
}
override fun onRealClick(item: Playlist) {
- navModel.exploreNavigateTo(item)
+ 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 62643f4cf..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,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
@@ -31,17 +30,15 @@ import org.oxycblt.auxio.databinding.FragmentHomeListBinding
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
-import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
@@ -58,10 +55,9 @@ class SongListFragment :
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
- override val navModel: NavigationViewModel 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 1ec38068e..f2858479c 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 bca4fb774..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,27 +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.MainFragmentDirections
-import org.oxycblt.auxio.R
-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.navigation.MainNavigationAction
-import org.oxycblt.auxio.navigation.NavigationViewModel
-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.
@@ -47,15 +29,6 @@ import org.oxycblt.auxio.util.showToast
*/
abstract class ListFragment :
SelectionFragment(), SelectableListListener {
- protected abstract val navModel: NavigationViewModel
- 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].
@@ -65,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)
@@ -75,309 +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 -> {
- navModel.exploreNavigateToParentArtist(song)
- true
- }
- R.id.action_go_album -> {
- navModel.exploreNavigateTo(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 -> {
- navModel.mainNavigateTo(
- MainNavigationAction.Directions(
- MainFragmentDirections.actionShowDetails(song.uid)))
- 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 -> {
- navModel.exploreNavigateToParentArtist(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/main/java/org/oxycblt/auxio/list/ListModule.kt b/app/src/main/java/org/oxycblt/auxio/list/ListModule.kt
new file mode 100644
index 000000000..521bc4283
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListModule.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ * 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
+ * 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 dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@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