diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 448ac933e..8a347a391 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -29,6 +29,8 @@ jobs: ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Test app with Gradle + run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d47c38f..d5088867b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 3.0.2 + +#### What's New +- Added ability to play/shuffle selections +- Redesigned header components +- Redesigned settings view + +#### What's Improved +- Added ability to edit previously played or currently playing items in the queue +- Added support for date values formatted as "YYYYMMDD" +- Pressing the button will now clear the current selection before navigating back +- Added support for non-standard `ARTISTS` tags +- Play Next and Add To Queue now start playback if there is no queue to add + +#### What's Fixed +- Fixed unreliable ReplayGain adjustment application in certain situations +- Fixed crash that would occur in music folders dialog when user does not have a working +file manager +- Fixed notification not updating due to settings changes +- Fixed genre picker from repeatedly showing up when device rotates +- Fixed multi-value genres not being recognized on vorbis files +- Fixed sharp-cornered widget bar appearing even when round mode was enabled +- Fixed duplicate song items from appearing + +#### What's Changed +- Implemented new queue system (will wipe state) + +#### Dev/Meta +- Added unit testing framework + ## 3.0.1 #### What's New diff --git a/README.md b/README.md index 6ade65990..fb76b3840 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index b0662424f..45bf5f5ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,15 +12,13 @@ android { defaultConfig { applicationId namespace - versionName "3.0.1" - versionCode 25 + versionName "3.0.2" + versionCode 26 minSdk 21 targetSdk 33 - buildFeatures { - viewBinding true - } + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } // ExoPlayer, AndroidX, and Material Components all need Java 8 to compile. @@ -36,8 +34,8 @@ android { buildTypes { debug { - applicationIdSuffix = ".debug" - versionNameSuffix = "-DEBUG" + applicationIdSuffix ".debug" + versionNameSuffix "-DEBUG" } release { @@ -47,6 +45,10 @@ android { } } + buildFeatures { + viewBinding true + } + dependenciesInfo { includeInApk = false includeInBundle = false @@ -110,8 +112,11 @@ dependencies { // Locked below 1.7.0-alpha03 to avoid the same ripple bug implementation "com.google.android.material:material:1.7.0-alpha02" - // LeakCanary + // Development debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1" + testImplementation "junit:junit:4.13.2" + androidTestImplementation 'androidx.test.ext:junit:1.1.4' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' } spotless { diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt new file mode 100644 index 000000000..7de0b5199 --- /dev/null +++ b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class StubTest { + // TODO: Make tests + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.oxycblt.auxio", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1c35add27..81daa45e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ extends CoordinatorLayout.Behavior { +public class BackportBottomSheetBehavior extends CoordinatorLayout.Behavior { /** Listener for monitoring events about bottom sheets. */ public abstract static class BottomSheetCallback { @@ -318,9 +319,9 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be private int expandHalfwayActionId = View.NO_ID; - public NeoBottomSheetBehavior() {} + public BackportBottomSheetBehavior() {} - public NeoBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { + public BackportBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); peekHeightGestureInsetBuffer = @@ -1980,7 +1981,7 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be skipCollapsed = source.readInt() == 1; } - public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior behavior) { + public SavedState(Parcelable superState, @NonNull BackportBottomSheetBehavior behavior) { super(superState); this.state = behavior.state; this.peekHeight = behavior.peekHeight; @@ -1990,12 +1991,12 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be } /** - * This constructor does not respect flags: {@link NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link - * NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link - * NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE} + * This constructor does not respect flags: {@link BackportBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link + * BackportBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link BackportBottomSheetBehavior#SAVE_HIDEABLE}, {@link + * BackportBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link BackportBottomSheetBehavior#SAVE_NONE} * were set. * - * @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead. + * @deprecated Use {@link #SavedState(Parcelable, BackportBottomSheetBehavior)} instead. */ @Deprecated public SavedState(Parcelable superstate, @State int state) { @@ -2036,24 +2037,24 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be } /** - * A utility function to get the {@link NeoBottomSheetBehavior} associated with the {@code view}. + * A utility function to get the {@link BackportBottomSheetBehavior} associated with the {@code view}. * - * @param view The {@link View} with {@link NeoBottomSheetBehavior}. - * @return The {@link NeoBottomSheetBehavior} associated with the {@code view}. + * @param view The {@link View} with {@link BackportBottomSheetBehavior}. + * @return The {@link BackportBottomSheetBehavior} associated with the {@code view}. */ @NonNull @SuppressWarnings("unchecked") - public static NeoBottomSheetBehavior from(@NonNull V view) { + public static BackportBottomSheetBehavior from(@NonNull V view) { ViewGroup.LayoutParams params = view.getLayoutParams(); if (!(params instanceof CoordinatorLayout.LayoutParams)) { throw new IllegalArgumentException("The view is not a child of CoordinatorLayout"); } CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) params).getBehavior(); - if (!(behavior instanceof NeoBottomSheetBehavior)) { + if (!(behavior instanceof BackportBottomSheetBehavior)) { throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior"); } - return (NeoBottomSheetBehavior) behavior; + return (BackportBottomSheetBehavior) behavior; } /** @@ -2200,3 +2201,4 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be }; } } + diff --git a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java new file mode 100644 index 000000000..066429513 --- /dev/null +++ b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java @@ -0,0 +1,412 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.divider; + +import com.google.android.material.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ItemDecoration; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.view.ViewCompat; +import com.google.android.material.internal.ThemeEnforcement; +import com.google.android.material.resources.MaterialResources; + +/** + * MaterialDividerItemDecoration is a {@link RecyclerView.ItemDecoration}, similar to a {@link + * androidx.recyclerview.widget.DividerItemDecoration}, that can be used as a divider between items of + * a {@link LinearLayoutManager}. It supports both {@link #HORIZONTAL} and {@link #VERTICAL} + * orientations. + * + *

+ *     dividerItemDecoration = new MaterialDividerItemDecoration(recyclerView.getContext(),
+ *             layoutManager.getOrientation());
+ *     recyclerView.addItemDecoration(dividerItemDecoration);
+ * 
+ * + * Modified at several points by Alexander Capehart to backport miscellaneous fixes not currently + * obtainable in the currently used MDC library. + */ +public class BackportMaterialDividerItemDecoration extends ItemDecoration { + public static final int HORIZONTAL = LinearLayout.HORIZONTAL; + public static final int VERTICAL = LinearLayout.VERTICAL; + + private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_MaterialDivider; + + @NonNull private Drawable dividerDrawable; + private int thickness; + @ColorInt private int color; + private int orientation; + private int insetStart; + private int insetEnd; + private boolean lastItemDecorated; + + private final Rect tempRect = new Rect(); + + public BackportMaterialDividerItemDecoration(@NonNull Context context, int orientation) { + this(context, null, orientation); + } + + public BackportMaterialDividerItemDecoration( + @NonNull Context context, @Nullable AttributeSet attrs, int orientation) { + this(context, attrs, R.attr.materialDividerStyle, orientation); + } + + public BackportMaterialDividerItemDecoration( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int orientation) { + TypedArray attributes = + ThemeEnforcement.obtainStyledAttributes( + context, attrs, R.styleable.MaterialDivider, defStyleAttr, DEF_STYLE_RES); + + color = + MaterialResources.getColorStateList( + context, attributes, R.styleable.MaterialDivider_dividerColor) + .getDefaultColor(); + thickness = + attributes.getDimensionPixelSize( + R.styleable.MaterialDivider_dividerThickness, + context.getResources().getDimensionPixelSize(R.dimen.material_divider_thickness)); + insetStart = + attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetStart, 0); + insetEnd = attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetEnd, 0); + lastItemDecorated = + attributes.getBoolean(R.styleable.MaterialDivider_lastItemDecorated, true); + + attributes.recycle(); + + dividerDrawable = new ShapeDrawable(); + setDividerColor(color); + setOrientation(orientation); + } + + /** + * Sets the orientation for this divider. This should be called if {@link + * RecyclerView.LayoutManager} changes orientation. + * + *

A {@link #HORIZONTAL} orientation will draw a vertical divider, and a {@link #VERTICAL} + * orientation a horizontal divider. + * + * @param orientation The orientation of the {@link RecyclerView} this divider is associated with: + * {@link #HORIZONTAL} or {@link #VERTICAL} + */ + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL && orientation != VERTICAL) { + throw new IllegalArgumentException( + "Invalid orientation: " + orientation + ". It should be either HORIZONTAL or VERTICAL"); + } + this.orientation = orientation; + } + + public int getOrientation() { + return orientation; + } + + /** + * Sets the thickness of the divider. + * + * @param thickness The thickness value to be set. + * @see #getDividerThickness() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + public void setDividerThickness(@Px int thickness) { + this.thickness = thickness; + } + + /** + * Sets the thickness of the divider. + * + * @param thicknessId The id of the thickness dimension resource to be set. + * @see #getDividerThickness() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + public void setDividerThicknessResource(@NonNull Context context, @DimenRes int thicknessId) { + setDividerThickness(context.getResources().getDimensionPixelSize(thicknessId)); + } + + /** + * Returns the thickness set on the divider. + * + * @see #setDividerThickness(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + @Px + public int getDividerThickness() { + return thickness; + } + + /** + * Sets the color of the divider. + * + * @param color The color to be set. + * @see #getDividerColor() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + public void setDividerColor(@ColorInt int color) { + this.color = color; + dividerDrawable = DrawableCompat.wrap(dividerDrawable); + DrawableCompat.setTint(dividerDrawable, color); + } + + /** + * Sets the color of the divider. + * + * @param colorId The id of the color resource to be set. + * @see #getDividerColor() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + public void setDividerColorResource(@NonNull Context context, @ColorRes int colorId) { + setDividerColor(ContextCompat.getColor(context, colorId)); + } + + /** + * Returns the divider color. + * + * @see #setDividerColor(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + @ColorInt + public int getDividerColor() { + return color; + } + + /** + * Sets the start inset of the divider. + * + * @param insetStart The start inset to be set. + * @see #getDividerInsetStart() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + public void setDividerInsetStart(@Px int insetStart) { + this.insetStart = insetStart; + } + + /** + * Sets the start inset of the divider. + * + * @param insetStartId The id of the inset dimension resource to be set. + * @see #getDividerInsetStart() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + public void setDividerInsetStartResource(@NonNull Context context, @DimenRes int insetStartId) { + setDividerInsetStart(context.getResources().getDimensionPixelOffset(insetStartId)); + } + + /** + * Returns the divider's start inset. + * + * @see #setDividerInsetStart(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + @Px + public int getDividerInsetStart() { + return insetStart; + } + + /** + * Sets the end inset of the divider. + * + * @param insetEnd The end inset to be set. + * @see #getDividerInsetEnd() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + public void setDividerInsetEnd(@Px int insetEnd) { + this.insetEnd = insetEnd; + } + + /** + * Sets the end inset of the divider. + * + * @param insetEndId The id of the inset dimension resource to be set. + * @see #getDividerInsetEnd() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + public void setDividerInsetEndResource(@NonNull Context context, @DimenRes int insetEndId) { + setDividerInsetEnd(context.getResources().getDimensionPixelOffset(insetEndId)); + } + + /** + * Returns the divider's end inset. + * + * @see #setDividerInsetEnd(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + @Px + public int getDividerInsetEnd() { + return insetEnd; + } + + /** + * Sets whether the class should draw a divider after the last item of a {@link RecyclerView}. + * + * @param lastItemDecorated whether there's a divider after the last item of a recycler view. + * @see #isLastItemDecorated() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_lastItemDecorated + */ + public void setLastItemDecorated(boolean lastItemDecorated) { + this.lastItemDecorated = lastItemDecorated; + } + + /** + * Whether there's a divider after the last item of a {@link RecyclerView}. + * + * @see #setLastItemDecorated(boolean) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_shouldDecorateLastItem + */ + public boolean isLastItemDecorated() { + return lastItemDecorated; + } + + @Override + public void onDraw( + @NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + if (parent.getLayoutManager() == null) { + return; + } + if (orientation == VERTICAL) { + drawForVerticalOrientation(canvas, parent); + } else { + drawForHorizontalOrientation(canvas, parent); + } + } + + /** + * Draws a divider for the vertical orientation of the recycler view. The divider itself will be + * horizontal. + */ + private void drawForVerticalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { + canvas.save(); + int left; + int right; + if (parent.getClipToPadding()) { + left = parent.getPaddingLeft(); + right = parent.getWidth() - parent.getPaddingRight(); + canvas.clipRect( + left, parent.getPaddingTop(), right, parent.getHeight() - parent.getPaddingBottom()); + } else { + left = 0; + right = parent.getWidth(); + } + boolean isRtl = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL; + left += isRtl ? insetEnd : insetStart; + right -= isRtl ? insetStart : insetEnd; + + int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + if (shouldDrawDivider(parent, child)) { + parent.getDecoratedBoundsWithMargins(child, tempRect); + // Take into consideration any translationY added to the view. + int bottom = tempRect.bottom + Math.round(child.getTranslationY()); + int top = bottom - thickness; + dividerDrawable.setBounds(left, top, right, bottom); + dividerDrawable.draw(canvas); + } + } + canvas.restore(); + } + + /** + * Draws a divider for the horizontal orientation of the recycler view. The divider itself will be + * vertical. + */ + private void drawForHorizontalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { + canvas.save(); + int top; + int bottom; + if (parent.getClipToPadding()) { + top = parent.getPaddingTop(); + bottom = parent.getHeight() - parent.getPaddingBottom(); + canvas.clipRect( + parent.getPaddingLeft(), top, parent.getWidth() - parent.getPaddingRight(), bottom); + } else { + top = 0; + bottom = parent.getHeight(); + } + top += insetStart; + bottom -= insetEnd; + + int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + if (shouldDrawDivider(parent, child)) { + parent.getDecoratedBoundsWithMargins(child, tempRect); + // Take into consideration any translationX added to the view. + int right = tempRect.right + Math.round(child.getTranslationX()); + int left = right - thickness; + dividerDrawable.setBounds(left, top, right, bottom); + dividerDrawable.draw(canvas); + } + } + canvas.restore(); + } + + @Override + public void getItemOffsets( + @NonNull Rect outRect, + @NonNull View view, + @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + outRect.set(0, 0, 0, 0); + // Only add offset if there's a divider displayed. + if (shouldDrawDivider(parent, view)) { + if (orientation == VERTICAL) { + outRect.bottom = thickness; + } else { + outRect.right = thickness; + } + } + } + + private boolean shouldDrawDivider(@NonNull RecyclerView parent, @NonNull View child) { + int position = parent.getChildAdapterPosition(child); + RecyclerView.Adapter adapter = parent.getAdapter(); + boolean isLastItem = adapter != null && position == adapter.getItemCount() - 1; + + return position != RecyclerView.NO_POSITION + && (!isLastItem || lastItemDecorated) + && shouldDrawDivider(position, adapter); + } + + /** + * Whether a divider should be drawn below the current item that is being drawn. + * + *

Note: if lasItemDecorated is false, the divider below the last item will never be drawn even + * if this method returns true. + * + * @param position the position of the current item being drawn. + * @param adapter the {@link RecyclerView.Adapter} associated with the item being drawn. + */ + protected boolean shouldDrawDivider(int position, @Nullable RecyclerView.Adapter adapter) { + return true; + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/AuxioApp.kt rename to app/src/main/java/org/oxycblt/auxio/Auxio.kt index 1e645d506..bcb8777e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt @@ -25,22 +25,26 @@ import androidx.core.graphics.drawable.IconCompat import coil.ImageLoader import coil.ImageLoaderFactory import coil.request.CachePolicy +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.ArtistImageFetcher import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory import org.oxycblt.auxio.image.extractor.GenreImageFetcher import org.oxycblt.auxio.image.extractor.MusicKeyer -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.ui.UISettings /** - * Auxio: A simple, rational music player for android. + * A simple, rational music player for android. * @author Alexander Capehart (OxygenCobalt) */ -class AuxioApp : Application(), ImageLoaderFactory { +class Auxio : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() // Migrate any settings that may have changed in an app update. - Settings(this).migrate() + ImageSettings.from(this).migrate() + PlaybackSettings.from(this).migrate() + UISettings.from(this).migrate() // Adding static shortcuts in a dynamic manner is better than declaring them // manually, as it will properly handle the difference between debug and release // Auxio instances. diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index 25d40c62c..201abbd18 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -29,7 +29,7 @@ import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.system.PlaybackService -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.androidViewModels import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.logD @@ -81,7 +81,7 @@ class MainActivity : AppCompatActivity() { } private fun setupTheme() { - val settings = Settings(this) + val settings = UISettings.from(this) // Apply the theme configuration. AppCompatDelegate.setDefaultNightMode(settings.theme) // Apply the color scheme. The black theme requires it's own set of themes since @@ -131,7 +131,7 @@ class MainActivity : AppCompatActivity() { val action = when (intent.action) { Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false) - AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll + Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll else -> return false } playbackModel.startAction(action) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c3a3060ce..8b93763f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -30,7 +30,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.transition.MaterialFadeThrough import kotlin.math.max @@ -101,10 +101,10 @@ class MainFragment : val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior unlikelyToBeNull(binding.handleWrapper).setOnClickListener { - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED && - queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && + queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is expanded and queue sheet is collapsed, we can expand it. - queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED + queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } } else { @@ -183,7 +183,7 @@ class MainFragment : // Playback sheet intercepts queue sheet touch events, prevent that from // occurring by disabling dragging whenever the queue sheet is expanded. playbackSheetBehavior.isDraggable = - queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED } } else { // No queue sheet, fade normally based on the playback sheet @@ -235,8 +235,8 @@ class MainFragment : tryHideAllSheets() } - // Since the listener is also reliant on the bottom sheets, we must also update it - // every frame. + // Since the navigation listener is also reliant on the bottom sheets, we must also update + // it every frame. callback.invalidateEnabled() return true @@ -309,7 +309,7 @@ class MainFragment : navModel.mainNavigateTo( MainNavigationAction.Directions( MainFragmentDirections.actionPickPlaybackGenre(song.uid))) - playbackModel.finishPlaybackArtistPicker() + playbackModel.finishPlaybackGenrePicker() } } @@ -317,9 +317,9 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is not expanded and not hidden, we can expand it. - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } @@ -327,12 +327,12 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { // Make sure the queue is also collapsed here. val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED - queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior?.state = BackportBottomSheetBehavior.STATE_COLLAPSED } } @@ -340,17 +340,15 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? - // Queue sheet behavior is either collapsed or expanded, no hiding needed queueSheetBehavior?.isDraggable = true - playbackSheetBehavior.apply { // Make sure the view is draggable, at least until the draw checks kick in. isDraggable = true - state = NeoBottomSheetBehavior.STATE_COLLAPSED + state = BackportBottomSheetBehavior.STATE_COLLAPSED } } } @@ -359,19 +357,19 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? // Make both bottom sheets non-draggable so the user can't halt the hiding event. queueSheetBehavior?.apply { isDraggable = false - state = NeoBottomSheetBehavior.STATE_COLLAPSED + state = BackportBottomSheetBehavior.STATE_COLLAPSED } playbackSheetBehavior.apply { isDraggable = false - state = NeoBottomSheetBehavior.STATE_HIDDEN + state = BackportBottomSheetBehavior.STATE_HIDDEN } } } @@ -390,16 +388,21 @@ class MainFragment : // If expanded, collapse the queue sheet first. if (queueSheetBehavior != null && - queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { - queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && + playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { + queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED return } // If expanded, collapse the playback sheet next. - if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED + if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && + playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED + return + } + + // Clear out any prior selections. + if (selectionModel.consume().isNotEmpty()) { return } @@ -425,8 +428,9 @@ class MainFragment : val exploreNavController = binding.exploreNavHost.findNavController() isEnabled = - queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED || - playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED || + queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || + playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || + selectionModel.selected.value.isNotEmpty() || 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 1d3d4f9f3..90689c188 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -31,26 +31,22 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.Song -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings -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.showToast -import org.oxycblt.auxio.util.unlikelyToBeNull +import org.oxycblt.auxio.music.library.Sort +import org.oxycblt.auxio.util.* /** * A [ListFragment] that shows information about an [Album]. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumDetailFragment : ListFragment(), AlbumDetailAdapter.Listener { +class AlbumDetailFragment : + ListFragment(), AlbumDetailAdapter.Listener { private val detailModel: DetailViewModel 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. @@ -88,7 +84,7 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd // DetailViewModel handles most initialization from the navigation argument. detailModel.setAlbumUid(args.albumUid) collectImmediately(detailModel.currentAlbum, ::updateAlbum) - collectImmediately(detailModel.albumList, detailAdapter::submitList) + collectImmediately(detailModel.albumList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -126,21 +122,12 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd } } - override fun onRealClick(music: Music) { - check(music is Song) { "Unexpected datatype: ${music::class.java}" } - when (Settings(requireContext()).detailPlaybackMode) { - // "Play from shown item" and "Play from album" functionally have the same - // behavior since a song can only have one album. - null, - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) - } + 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) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onOpenMenu(item: Song, anchor: View) { openMusicMenu(anchor, R.menu.menu_album_song_actions, item) } @@ -154,12 +141,12 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_album_sort) { - val sort = detailModel.albumSort + val sort = detailModel.albumSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.albumSort = + detailModel.albumSongSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { @@ -185,10 +172,10 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) { - detailAdapter.setPlayingItem(song, isPlaying) + detailAdapter.setPlaying(song, isPlaying) } else { // Clear the ViewHolders if the mode isn't ALL_SONGS - detailAdapter.setPlayingItem(null, isPlaying) + detailAdapter.setPlaying(null, isPlaying) } } @@ -272,8 +259,12 @@ class AlbumDetailFragment : ListFragment(), AlbumDetailAd } } + private fun updateList(items: List) { + detailAdapter.submitList(items, BasicListInstructions.DIFF) + } + private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } 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 e1d103861..5734ee1a8 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -31,14 +31,13 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.Song -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -49,7 +48,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information about an [Artist]. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailFragment : ListFragment(), DetailAdapter.Listener { +class ArtistDetailFragment : + ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel 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. @@ -87,7 +87,7 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte // DetailViewModel handles most initialization from the navigation argument. detailModel.setArtistUid(args.artistUid) collectImmediately(detailModel.currentArtist, ::updateItem) - collectImmediately(detailModel.artistList, detailAdapter::submitList) + collectImmediately(detailModel.artistList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -121,27 +121,25 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte } } - override fun onRealClick(music: Music) { - when (music) { + override fun onRealClick(item: Music) { + when (item) { + is Album -> navModel.exploreNavigateTo(item) is Song -> { - when (Settings(requireContext()).detailPlaybackMode) { + 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. - null -> - playbackModel.playFromArtist( - music, unlikelyToBeNull(detailModel.currentArtist.value)) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + playbackModel.playFromArtist( + item, unlikelyToBeNull(detailModel.currentArtist.value)) } } - is Album -> navModel.exploreNavigateTo(music) - else -> error("Unexpected datatype: ${music::class.simpleName}") + else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item) @@ -159,13 +157,13 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_artist_sort) { - val sort = detailModel.artistSort + val sort = detailModel.artistSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.artistSort = + detailModel.artistSongSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { @@ -199,7 +197,7 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte else -> null } - detailAdapter.setPlayingItem(playingItem, isPlaying) + detailAdapter.setPlaying(playingItem, isPlaying) } private fun handleNavigation(item: Music?) { @@ -237,8 +235,12 @@ class ArtistDetailFragment : ListFragment(), DetailAdapte } } + private fun updateList(items: List) { + detailAdapter.submitList(items, BasicListInstructions.DIFF) + } + private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt index d5b32e584..1788b4ddd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -20,17 +20,19 @@ package org.oxycblt.auxio.detail import androidx.annotation.StringRes import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.MimeType +import org.oxycblt.auxio.music.storage.MimeType /** * A header variation that displays a button to open a sort menu. * @param titleRes The string resource to use as the header title + * @author Alexander Capehart (OxygenCobalt) */ data class SortHeader(@StringRes val titleRes: Int) : Item /** * A header variation that delimits between disc groups. * @param disc The disc number to be displayed on the header. + * @author Alexander Capehart (OxygenCobalt) */ data class DiscHeader(val disc: Int) : Item @@ -39,6 +41,7 @@ data class DiscHeader(val disc: Int) : Item * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed. * @param sampleRateHz The sample rate, in hertz. * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined. + * @author Alexander Capehart (OxygenCobalt) */ data class SongProperties( val bitrateKbps: Int?, 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 2f7a404e0..d51cd3734 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -32,15 +32,13 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item -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.* import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.music.filesystem.MimeType -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort +import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.music.tags.ReleaseType +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* /** @@ -53,7 +51,8 @@ import org.oxycblt.auxio.util.* class DetailViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(application) + private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private var currentSongJob: Job? = null @@ -81,10 +80,10 @@ class DetailViewModel(application: Application) : get() = _albumList /** The current [Sort] used for [Song]s in [albumList]. */ - var albumSort: Sort - get() = settings.detailAlbumSort + var albumSongSort: Sort + get() = musicSettings.albumSongSort set(value) { - settings.detailAlbumSort = value + musicSettings.albumSongSort = value // Refresh the album list to reflect the new sort. currentAlbum.value?.let(::refreshAlbumList) } @@ -101,10 +100,10 @@ class DetailViewModel(application: Application) : val artistList: StateFlow> = _artistList /** The current [Sort] used for [Song]s in [artistList]. */ - var artistSort: Sort - get() = settings.detailArtistSort + var artistSongSort: Sort + get() = musicSettings.artistSongSort set(value) { - settings.detailArtistSort = value + musicSettings.artistSongSort = value // Refresh the artist list to reflect the new sort. currentArtist.value?.let(::refreshArtistList) } @@ -121,14 +120,21 @@ class DetailViewModel(application: Application) : val genreList: StateFlow> = _genreList /** The current [Sort] used for [Song]s in [genreList]. */ - var genreSort: Sort - get() = settings.detailGenreSort + var genreSongSort: Sort + get() = musicSettings.genreSongSort set(value) { - settings.detailGenreSort = value + musicSettings.genreSongSort = value // Refresh the genre list to reflect the new sort. currentGenre.value?.let(::refreshGenreList) } + /** + * 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 + init { musicStore.addListener(this) } @@ -137,7 +143,7 @@ class DetailViewModel(application: Application) : musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library == null) { // Nothing to do. return @@ -173,8 +179,8 @@ class DetailViewModel(application: Application) : } /** - * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] - * and [songProperties] will be updated to align with the new [Song]. + * Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and + * [songProperties] will be updated to align with the new [Song]. * @param uid The UID of the [Song] to load. Must be valid. */ fun setSongUid(uid: Music.UID) { @@ -315,7 +321,7 @@ class DetailViewModel(application: Application) : // To create a good user experience regarding disc numbers, we group the album's // songs up by disc and then delimit the groups by a disc header. - val songs = albumSort.songs(album.songs) + val songs = albumSongSort.songs(album.songs) // Songs without disc tags become part of Disc 1. val byDisc = songs.groupBy { it.disc ?: 1 } if (byDisc.size > 1) { @@ -339,21 +345,21 @@ class DetailViewModel(application: Application) : val byReleaseGroup = albums.groupBy { - // Remap the complicated Album.Type data structure into an easier + // Remap the complicated ReleaseType data structure into an easier // "AlbumGrouping" enum that will automatically group and sort // the artist's albums. - when (it.type.refinement) { - Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE - Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES + when (it.releaseType.refinement) { + ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE + ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES null -> - when (it.type) { - is Album.Type.Album -> AlbumGrouping.ALBUMS - is Album.Type.EP -> AlbumGrouping.EPS - is Album.Type.Single -> AlbumGrouping.SINGLES - is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS - is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is Album.Type.Mix -> AlbumGrouping.MIXES - is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES + when (it.releaseType) { + is ReleaseType.Album -> AlbumGrouping.ALBUMS + is ReleaseType.EP -> AlbumGrouping.EPS + is ReleaseType.Single -> AlbumGrouping.SINGLES + is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS + is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS + is ReleaseType.Mix -> AlbumGrouping.MIXES + is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES } } } @@ -369,7 +375,7 @@ class DetailViewModel(application: Application) : if (artist.songs.isNotEmpty()) { logD("Songs present in this artist, adding header") data.add(SortHeader(R.string.lbl_songs)) - data.addAll(artistSort.songs(artist.songs)) + data.addAll(artistSongSort.songs(artist.songs)) } _artistList.value = data.toList() @@ -382,12 +388,12 @@ class DetailViewModel(application: Application) : data.add(Header(R.string.lbl_artists)) data.addAll(genre.artists) data.add(SortHeader(R.string.lbl_songs)) - data.addAll(genreSort.songs(genre.songs)) + data.addAll(genreSongSort.songs(genre.songs)) _genreList.value = data } /** - * A simpler mapping of [Album.Type] used for grouping and sorting songs. + * A simpler mapping of [ReleaseType] used for grouping and sorting songs. * @param headerTitleRes The title string resource to use for a header created out of an * instance of this enum. */ 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 bfeca52c3..5d6ac4482 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -31,15 +31,14 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -50,7 +49,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ListFragment] that shows information for a particular [Genre]. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailFragment : ListFragment(), DetailAdapter.Listener { +class GenreDetailFragment : + ListFragment(), DetailAdapter.Listener { private val detailModel: DetailViewModel 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. @@ -86,7 +86,7 @@ class GenreDetailFragment : ListFragment(), DetailAdapter // DetailViewModel handles most initialization from the navigation argument. detailModel.setGenreUid(args.genreUid) collectImmediately(detailModel.currentGenre, ::updateItem) - collectImmediately(detailModel.genreList, detailAdapter::submitList) + collectImmediately(detailModel.genreList, ::updateList) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) collect(navModel.exploreNavigationItem, ::handleNavigation) @@ -120,26 +120,25 @@ class GenreDetailFragment : ListFragment(), DetailAdapter } } - override fun onRealClick(music: Music) { - when (music) { - is Artist -> navModel.exploreNavigateTo(music) - is Song -> - when (Settings(requireContext()).detailPlaybackMode) { - // When configured to play from the selected item, we already have a Genre + 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. - null -> - playbackModel.playFromGenre( - music, unlikelyToBeNull(detailModel.currentGenre.value)) - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) + playbackModel.playFromGenre( + item, unlikelyToBeNull(detailModel.currentGenre.value)) } - else -> error("Unexpected datatype: ${music::class.simpleName}") + } + else -> error("Unexpected datatype: ${item::class.simpleName}") } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) @@ -157,12 +156,12 @@ class GenreDetailFragment : ListFragment(), DetailAdapter override fun onOpenSortMenu(anchor: View) { openMenu(anchor, R.menu.menu_genre_sort) { - val sort = detailModel.genreSort + val sort = detailModel.genreSongSort unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending setOnMenuItemClickListener { item -> item.isChecked = !item.isChecked - detailModel.genreSort = + detailModel.genreSongSort = if (item.itemId == R.id.option_sort_asc) { sort.withAscending(item.isChecked) } else { @@ -184,17 +183,15 @@ class GenreDetailFragment : ListFragment(), DetailAdapter } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - var item: Item? = null - + var playingMusic: Music? = null if (parent is Artist) { - item = parent + playingMusic = parent } - + // Prefer songs that might be playing from this genre. if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) { - item = song + playingMusic = song } - - detailAdapter.setPlayingItem(item, isPlaying) + detailAdapter.setPlaying(playingMusic, isPlaying) } private fun handleNavigation(item: Music?) { @@ -221,8 +218,12 @@ class GenreDetailFragment : ListFragment(), DetailAdapter } } + private fun updateList(items: List) { + detailAdapter.submitList(items, BasicListInstructions.DIFF) + } + private fun updateSelection(selected: List) { - detailAdapter.setSelectedItems(selected) + detailAdapter.setSelected(selected.toSet()) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index 327038255..f0e0924e0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -30,7 +30,7 @@ import org.oxycblt.auxio.R * * Adapted from Material Files: https://github.com/zhanghai/MaterialFiles * - * @author Alexander Capehart (OxygenCobalt) + * @author Hai Zhang, Alexander Capehart (OxygenCobalt) */ class ReadOnlyTextInput @JvmOverloads diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index 8cc214b14..6a8611cb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -29,8 +29,8 @@ import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.detail.DiscHeader import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SimpleItemCallback +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.formatDurationMs @@ -48,7 +48,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene * An extension to [DetailAdapter.Listener] that enables interactions specific to the album * detail view. */ - interface Listener : DetailAdapter.Listener { + interface Listener : DetailAdapter.Listener { /** * Called when the artist name in the [Album] header was clicked, requesting navigation to * it's parent artist. @@ -57,7 +57,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene } override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support the Album header, sub-headers for each disc, and special album songs. is Album -> AlbumDetailViewHolder.VIEW_TYPE is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE @@ -75,7 +75,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item) is Song -> (holder as AlbumSongViewHolder).bind(item, listener) @@ -83,15 +83,18 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // The album and disc headers should be full-width in all configurations. - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Album || item is DiscHeader + val item = getItem(position) + return item is Album || item is DiscHeader } private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Album && newItem is Album -> @@ -126,7 +129,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite binding.detailCover.bind(album) // The type text depends on the release type (Album, EP, Single, etc.) - binding.detailType.text = binding.context.getString(album.type.stringRes) + binding.detailType.text = binding.context.getString(album.releaseType.stringRes) binding.detailName.text = album.resolveName(binding.context) @@ -166,14 +169,14 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem) && oldItem.dates == newItem.dates && oldItem.songs.size == newItem.songs.size && oldItem.durationMs == newItem.durationMs && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } @@ -207,7 +210,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = oldItem.disc == newItem.disc } @@ -226,7 +229,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA * @param song The new [Song] to bind. * @param listener A [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songTrack.apply { @@ -274,7 +277,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 84aedabfb..e706efc23 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -28,10 +28,11 @@ import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SimpleItemCallback +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -42,9 +43,10 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class ArtistDetailAdapter(private val listener: Listener) : + DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support an artist header, and special artist albums/songs. is Artist -> ArtistDetailViewHolder.VIEW_TYPE is Album -> ArtistAlbumViewHolder.VIEW_TYPE @@ -63,7 +65,7 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) // Re-binding an item with new data and not just a changed selection/playing state. - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener) is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener) is Song -> (holder as ArtistSongViewHolder).bind(item, listener) @@ -71,15 +73,17 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // Artist headers should be full-width in all configurations. - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Artist + return getItem(position) is Artist } private companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Artist && newItem is Artist -> @@ -109,7 +113,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It * @param artist The new [Artist] to bind. * @param listener A [DetailAdapter.Listener] to bind interactions to. */ - fun bind(artist: Artist, listener: DetailAdapter.Listener) { + fun bind(artist: Artist, listener: DetailAdapter.Listener<*>) { binding.detailCover.bind(artist) binding.detailType.text = binding.context.getString(R.string.lbl_artist) binding.detailName.text = artist.resolveName(binding.context) @@ -161,7 +165,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.rawName == newItem.rawName && oldItem.areGenreContentsTheSame(newItem) && @@ -183,7 +187,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite * @param album The new [Album] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(album: Album, listener: SelectableListListener) { + fun bind(album: Album, listener: SelectableListListener) { listener.bind(album, this, menuButton = binding.parentMenu) binding.parentImage.bind(album) binding.parentName.text = album.resolveName(binding.context) @@ -216,7 +220,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates } @@ -235,7 +239,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item * @param song The new [Song] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) @@ -265,7 +269,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.album.rawName == newItem.album.rawName diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 8775f4b9b..2ce12a786 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.detail.recycler import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable @@ -29,26 +28,29 @@ import org.oxycblt.auxio.detail.SortHeader import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.SelectableListListener +import org.oxycblt.auxio.list.adapter.* import org.oxycblt.auxio.list.recycler.* +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater /** * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters. * @param listener A [Listener] to bind interactions to. - * @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the * internal list. * @author Alexander Capehart (OxygenCobalt) */ abstract class DetailAdapter( - private val listener: Listener, - itemCallback: DiffUtil.ItemCallback -) : SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - // Safe to leak this since the listener will not fire during initialization - @Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback) + private val listener: Listener<*>, + diffCallback: DiffUtil.ItemCallback +) : + SelectionIndicatorAdapter( + ListDiffer.Async(diffCallback)), + AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Implement support for headers and sort headers is Header -> HeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE @@ -63,7 +65,7 @@ abstract class DetailAdapter( } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Header -> (holder as HeaderViewHolder).bind(item) is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener) } @@ -71,24 +73,12 @@ abstract class DetailAdapter( override fun isItemFullWidth(position: Int): Boolean { // Headers should be full-width in all configurations. - val item = differ.currentList[position] + val item = getItem(position) return item is Header || item is SortHeader } - override val currentList: List - get() = differ.currentList - - /** - * Asynchronously update the list with new items. Assumes that the list only contains data - * supported by the concrete [DetailAdapter] implementation. - * @param newList The new [Item]s for the adapter to display. - */ - fun submitList(newList: List) { - differ.submitList(newList) - } - /** An extended [SelectableListListener] for [DetailAdapter] implementations. */ - interface Listener : SelectableListListener { + interface Listener : SelectableListListener { // TODO: Split off into sub-listeners if a collapsing toolbar is implemented. /** * Called when the play button in a detail header is pressed, requesting that the current @@ -112,7 +102,7 @@ abstract class DetailAdapter( protected companion object { /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Header && newItem is Header -> @@ -138,7 +128,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : * @param sortHeader The new [SortHeader] to bind. * @param listener An [DetailAdapter.Listener] to bind interactions to. */ - fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) { + fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener<*>) { binding.headerTitle.text = binding.context.getString(sortHeader.titleRes) binding.headerButton.apply { // Add a Tooltip based on the content description so that the purpose of this @@ -162,7 +152,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) = oldItem.titleRes == newItem.titleRes } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index fad27aa8a..51a86f335 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -25,11 +25,12 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.ArtistViewHolder -import org.oxycblt.auxio.list.recycler.SimpleItemCallback import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural @@ -40,12 +41,13 @@ import org.oxycblt.auxio.util.inflater * @param listener A [DetailAdapter.Listener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) { +class GenreDetailAdapter(private val listener: Listener) : + DetailAdapter(listener, DIFF_CALLBACK) { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { // Support the Genre header and generic Artist/Song items. There's nothing about - // a genre that will make the artists/songs homogeneous, so it doesn't matter what we - // use for their ViewHolders. + // a genre that will make the artists/songs specially formatted, so it doesn't matter + // what we use for their ViewHolders. is Genre -> GenreDetailViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE is Song -> SongViewHolder.VIEW_TYPE @@ -62,7 +64,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { super.onBindViewHolder(holder, position) - when (val item = differ.currentList[position]) { + when (val item = getItem(position)) { is Genre -> (holder as GenreDetailViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) is Song -> (holder as SongViewHolder).bind(item, listener) @@ -70,14 +72,16 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene } override fun isItemFullWidth(position: Int): Boolean { + if (super.isItemFullWidth(position)) { + return true + } // Genre headers should be full-width in all configurations - val item = differ.currentList[position] - return super.isItemFullWidth(position) || item is Genre + return getItem(position) is Genre } private companion object { val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return when { oldItem is Genre && newItem is Genre -> @@ -105,7 +109,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite * @param genre The new [Song] to bind. * @param listener A [DetailAdapter.Listener] to bind interactions to. */ - fun bind(genre: Genre, listener: DetailAdapter.Listener) { + fun bind(genre: Genre, listener: DetailAdapter.Listener<*>) { binding.detailCover.bind(genre) binding.detailType.text = binding.context.getString(R.string.lbl_genre) binding.detailName.text = genre.resolveName(binding.context) @@ -135,7 +139,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre) = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size && 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 efa3d86f6..39ae497f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -50,6 +50,8 @@ import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel @@ -143,7 +145,7 @@ class HomeFragment : // --- VIEWMODEL SETUP --- collect(homeModel.shouldRecreate, ::handleRecreate) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) - collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab) + collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexerState, ::updateIndexerState) collect(navModel.exploreNavigationItem, ::handleNavigation) collectImmediately(selectionModel.selected, ::updateSelection) @@ -333,10 +335,7 @@ class HomeFragment : } } - private fun setupCompleteState( - binding: FragmentHomeBinding, - result: Result - ) { + private fun setupCompleteState(binding: FragmentHomeBinding, result: Result) { if (result.isSuccess) { logD("Received ok response") binding.homeFab.show() diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt new file mode 100644 index 000000000..c36e9d838 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * User configuration specific to the home UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface HomeSettings : Settings { + /** The tabs to show in the home UI. */ + var homeTabs: Array + /** Whether to hide artists considered "collaborators" from the home UI. */ + val shouldHideCollaborators: Boolean + + interface Listener { + /** Called when the [homeTabs] configuration changes. */ + fun onTabsChanged() + /** Called when the [shouldHideCollaborators] configuration changes. */ + fun onHideCollaboratorsChanged() + } + + private class Real(context: Context) : Settings.Real(context), HomeSettings { + override var homeTabs: Array + get() = + Tab.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT)) + ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value)) + apply() + } + } + + override val shouldHideCollaborators: Boolean + get() = + sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false) + + override fun onSettingChanged(key: String, listener: Listener) { + when (key) { + getString(R.string.set_key_home_tabs) -> listener.onTabsChanged() + getString(R.string.set_key_hide_collaborators) -> + listener.onHideCollaboratorsChanged() + } + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): HomeSettings = Real(context) + } +} 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 f9e88e3ef..63e6058bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -18,21 +18,15 @@ package org.oxycblt.auxio.home import android.app.Application -import android.content.SharedPreferences import androidx.lifecycle.AndroidViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.Tab -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.* import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings -import org.oxycblt.auxio.util.context +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD /** @@ -40,15 +34,15 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class HomeViewModel(application: Application) : - AndroidViewModel(application), - MusicStore.Listener, - SharedPreferences.OnSharedPreferenceChangeListener { + AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(application) + private val homeSettings = HomeSettings.from(application) + private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private val _songsList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ - val songLists: StateFlow> + val songsList: StateFlow> get() = _songsList private val _albumsLists = MutableStateFlow(listOf()) @@ -70,11 +64,15 @@ class HomeViewModel(application: Application) : val genresList: StateFlow> get() = _genresList + /** The [MusicMode] to use when playing a [Song] from the UI. */ + val playbackMode: MusicMode + get() = playbackSettings.inListPlaybackMode + /** * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabModes: List = makeTabModes() + var currentTabModes = makeTabModes() private set private val _currentTabMode = MutableStateFlow(currentTabModes[0]) @@ -95,45 +93,82 @@ class HomeViewModel(application: Application) : init { musicStore.addListener(this) - settings.addListener(this) + homeSettings.registerListener(this) } override fun onCleared() { super.onCleared() musicStore.removeListener(this) - settings.removeListener(this) + homeSettings.unregisterListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { logD("Library changed, refreshing library") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. - _songsList.value = settings.libSongSort.songs(library.songs) - _albumsLists.value = settings.libAlbumSort.albums(library.albums) + _songsList.value = musicSettings.songSort.songs(library.songs) + _albumsLists.value = musicSettings.albumSort.albums(library.albums) _artistsList.value = - settings.libArtistSort.artists( - if (settings.shouldHideCollaborators) { + musicSettings.artistSort.artists( + if (homeSettings.shouldHideCollaborators) { // Hide Collaborators is enabled, filter out collaborators. library.artists.filter { !it.isCollaborator } } else { library.artists }) - _genresList.value = settings.libGenreSort.genres(library.genres) + _genresList.value = musicSettings.genreSort.genres(library.genres) } } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - context.getString(R.string.set_key_lib_tabs) -> { - // Tabs changed, update the current tabs and set up a re-create event. - currentTabModes = makeTabModes() - _shouldRecreate.value = true + override fun onTabsChanged() { + // Tabs changed, update the current tabs and set up a re-create event. + currentTabModes = makeTabModes() + _shouldRecreate.value = true + } + + override fun onHideCollaboratorsChanged() { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + onLibraryChanged(musicStore.library) + } + + /** + * Get the preferred [Sort] for a given [Tab]. + * @param tabMode The [MusicMode] of the [Tab] desired. + * @return The [Sort] preferred for that [Tab] + */ + fun getSortForTab(tabMode: MusicMode) = + when (tabMode) { + MusicMode.SONGS -> musicSettings.songSort + MusicMode.ALBUMS -> musicSettings.albumSort + MusicMode.ARTISTS -> musicSettings.artistSort + MusicMode.GENRES -> musicSettings.genreSort + } + + /** + * Update the preferred [Sort] for the current [Tab]. Will update corresponding list. + * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. + */ + fun setSortForCurrentTab(sort: Sort) { + logD("Updating ${_currentTabMode.value} sort to $sort") + // Can simply re-sort the current list of items without having to access the library. + when (_currentTabMode.value) { + MusicMode.SONGS -> { + musicSettings.songSort = sort + _songsList.value = sort.songs(_songsList.value) } - context.getString(R.string.set_key_hide_collaborators) -> { - // Changes in the hide collaborator setting will change the artist contents - // of the library, consider it a library update. - onLibraryChanged(musicStore.library) + MusicMode.ALBUMS -> { + musicSettings.albumSort = sort + _albumsLists.value = sort.albums(_albumsLists.value) + } + MusicMode.ARTISTS -> { + musicSettings.artistSort = sort + _artistsList.value = sort.artists(_artistsList.value) + } + MusicMode.GENRES -> { + musicSettings.genreSort = sort + _genresList.value = sort.genres(_genresList.value) } } } @@ -155,46 +190,6 @@ class HomeViewModel(application: Application) : _shouldRecreate.value = false } - /** - * Get the preferred [Sort] for a given [Tab]. - * @param tabMode The [MusicMode] of the [Tab] desired. - * @return The [Sort] preferred for that [Tab] - */ - fun getSortForTab(tabMode: MusicMode) = - when (tabMode) { - MusicMode.SONGS -> settings.libSongSort - MusicMode.ALBUMS -> settings.libAlbumSort - MusicMode.ARTISTS -> settings.libArtistSort - MusicMode.GENRES -> settings.libGenreSort - } - - /** - * Update the preferred [Sort] for the current [Tab]. Will update corresponding list. - * @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab]. - */ - fun setSortForCurrentTab(sort: Sort) { - logD("Updating ${_currentTabMode.value} sort to $sort") - // Can simply re-sort the current list of items without having to access the library. - when (_currentTabMode.value) { - MusicMode.SONGS -> { - settings.libSongSort = sort - _songsList.value = sort.songs(_songsList.value) - } - MusicMode.ALBUMS -> { - settings.libAlbumSort = sort - _albumsLists.value = sort.albums(_albumsLists.value) - } - MusicMode.ARTISTS -> { - settings.libArtistSort = sort - _artistsList.value = sort.artists(_artistsList.value) - } - MusicMode.GENRES -> { - settings.libGenreSort = sort - _genresList.value = sort.genres(_genresList.value) - } - } - } - /** * Update whether the user is fast scrolling or not in the home view. * @param isFastScrolling true if the user is currently fast scrolling, false otherwise. @@ -209,5 +204,6 @@ class HomeViewModel(application: Application) : * @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in * the same way as the configuration. */ - private fun makeTabModes() = settings.libTabs.filterIsInstance().map { it.mode } + private fun makeTabModes() = + homeSettings.homeTabs.filterIsInstance().map { it.mode } } 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 ad91fdade..e56a9c39e 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 @@ -30,14 +30,12 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.AlbumViewHolder -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer -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.Sort +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.util.collectImmediately @@ -47,7 +45,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class AlbumListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.Listener, FastScrollRecyclerView.PopupProvider { private val homeModel: HomeViewModel by activityViewModels() @@ -69,8 +67,8 @@ class AlbumListFragment : listener = this@AlbumListFragment } - collectImmediately(homeModel.albumsList, albumAdapter::replaceList) - collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems) + collectImmediately(homeModel.albumsList, ::updateList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -125,45 +123,40 @@ class AlbumListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Album) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Album) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Album) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Album, anchor: View) { openMusicMenu(anchor, R.menu.menu_album_actions, item) } + private fun updateList(albums: List) { + albumAdapter.submitList(albums, BasicListInstructions.REPLACE) + } + + private fun updateSelection(selection: List) { + albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If an album is playing, highlight it within this adapter. - albumAdapter.setPlayingItem(parent as? Album, isPlaying) + albumAdapter.setPlaying(parent as? Album, isPlaying) } /** * A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class AlbumAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + private class AlbumAdapter(private val listener: SelectableListListener) : + SelectionIndicatorAdapter( + ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AlbumViewHolder.from(parent) override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Album]s. - * @param newList The new [Album]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 2dd51edf6..a0a4aed08 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 @@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.ArtistViewHolder -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer 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.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.nonZeroOrNull @@ -45,11 +46,11 @@ import org.oxycblt.auxio.util.nonZeroOrNull * @author Alexander Capehart (OxygenCobalt) */ class ArtistListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = ArtistAdapter(this) + private val artistAdapter = ArtistAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -59,13 +60,13 @@ class ArtistListFragment : binding.homeRecycler.apply { id = R.id.home_artist_recycler - adapter = homeAdapter + adapter = artistAdapter popupProvider = this@ArtistListFragment listener = this@ArtistListFragment } - collectImmediately(homeModel.artistsList, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.artistsList, ::updateList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -100,45 +101,40 @@ class ArtistListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Artist) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Artist) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Artist) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Artist, anchor: View) { openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + private fun updateList(artists: List) { + artistAdapter.submitList(artists, BasicListInstructions.REPLACE) + } + + private fun updateSelection(selection: List) { + artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If an artist is playing, highlight it within this adapter. - homeAdapter.setPlayingItem(parent as? Artist, isPlaying) + artistAdapter.setPlaying(parent as? Artist, isPlaying) } /** * A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class ArtistAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + private class ArtistAdapter(private val listener: SelectableListListener) : + SelectionIndicatorAdapter( + ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ArtistViewHolder.from(parent) override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Artist]s. - * @param newList The new [Artist]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 fa3484d90..83b49723d 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 @@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.GenreViewHolder -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter -import org.oxycblt.auxio.list.recycler.SyncListDiffer 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.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collectImmediately @@ -44,11 +45,11 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class GenreListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = GenreAdapter(this) + private val genreAdapter = GenreAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeListBinding.inflate(inflater) @@ -58,13 +59,13 @@ class GenreListFragment : binding.homeRecycler.apply { id = R.id.home_genre_recycler - adapter = homeAdapter + adapter = genreAdapter popupProvider = this@GenreListFragment listener = this@GenreListFragment } - collectImmediately(homeModel.genresList, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.genresList, ::updateList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -99,45 +100,39 @@ class GenreListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Genre) { "Unexpected datatype: ${music::class.java}" } - navModel.exploreNavigateTo(music) + override fun onRealClick(item: Genre) { + navModel.exploreNavigateTo(item) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Genre) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Genre, anchor: View) { openMusicMenu(anchor, R.menu.menu_artist_actions, item) } + private fun updateList(artists: List) { + genreAdapter.submitList(artists, BasicListInstructions.REPLACE) + } + + private fun updateSelection(selection: List) { + genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) { // If a genre is playing, highlight it within this adapter. - homeAdapter.setPlayingItem(parent as? Genre, isPlaying) + genreAdapter.setPlaying(parent as? Genre, isPlaying) } /** * A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class GenreAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList - + private class GenreAdapter(private val listener: SelectableListListener) : + SelectionIndicatorAdapter( + ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = GenreViewHolder.from(parent) override fun onBindViewHolder(holder: GenreViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Genre]s. - * @param newList The new [Genre]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 0b85bcb5f..eba715958 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 @@ -30,17 +30,17 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment -import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.collectImmediately /** @@ -48,11 +48,11 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ class SongListFragment : - ListFragment(), + ListFragment(), FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.Listener { private val homeModel: HomeViewModel by activityViewModels() - private val homeAdapter = SongAdapter(this) + 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) private val formatter = Formatter(formatterSb) @@ -65,13 +65,13 @@ class SongListFragment : binding.homeRecycler.apply { id = R.id.home_song_recycler - adapter = homeAdapter + adapter = songAdapter popupProvider = this@SongListFragment listener = this@SongListFragment } - collectImmediately(homeModel.songLists, homeAdapter::replaceList) - collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems) + collectImmediately(homeModel.songsList, ::updateList) + collectImmediately(selectionModel.selected, ::updateSelection) collectImmediately( playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) } @@ -86,7 +86,7 @@ class SongListFragment : } override fun getPopup(pos: Int): String? { - val song = homeModel.songLists.value[pos] + val song = homeModel.songsList.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. @@ -130,27 +130,28 @@ class SongListFragment : homeModel.setFastScrolling(isFastScrolling) } - override fun onRealClick(music: Music) { - check(music is Song) { "Unexpected datatype: ${music::class.java}" } - when (Settings(requireContext()).libPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) - } + override fun onRealClick(item: Song) { + playbackModel.playFrom(item, homeModel.playbackMode) } - override fun onOpenMenu(item: Item, anchor: View) { - check(item is Song) { "Unexpected datatype: ${item::class.java}" } + override fun onOpenMenu(item: Song, anchor: View) { openMusicMenu(anchor, R.menu.menu_song_actions, item) } + private fun updateList(songs: List) { + songAdapter.submitList(songs, BasicListInstructions.REPLACE) + } + + private fun updateSelection(selection: List) { + songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) + } + private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { if (parent == null) { - homeAdapter.setPlayingItem(song, isPlaying) + songAdapter.setPlaying(song, isPlaying) } else { // Ignore playback that is not from all songs - homeAdapter.setPlayingItem(null, isPlaying) + songAdapter.setPlaying(null, isPlaying) } } @@ -158,26 +159,15 @@ class SongListFragment : * A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder]. * @param listener An [SelectableListListener] to bind interactions to. */ - private class SongAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter() { - private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList + private class SongAdapter(private val listener: SelectableListListener) : + SelectionIndicatorAdapter( + ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = SongViewHolder.from(parent) override fun onBindViewHolder(holder: SongViewHolder, position: Int) { - holder.bind(differ.currentList[position], listener) - } - - /** - * Asynchronously update the list with new [Song]s. - * @param newList The new [Song]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) + holder.bind(getItem(position), listener) } } } 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 1e74ad396..76f7cf95d 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 @@ -17,7 +17,6 @@ package org.oxycblt.auxio.home.tabs -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.util.logE @@ -26,7 +25,7 @@ import org.oxycblt.auxio.util.logE * @param mode The type of list in the home view this instance corresponds to. * @author Alexander Capehart (OxygenCobalt) */ -sealed class Tab(open val mode: MusicMode) : Item { +sealed class Tab(open val mode: MusicMode) { /** * 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. 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 756f8fea1..c60084cf9 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 @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration. * @param listener A [EditableListListener] for tab interactions. */ -class TabAdapter(private val listener: EditableListListener) : +class TabAdapter(private val listener: EditableListListener) : RecyclerView.Adapter() { /** The current array of [Tab]s. */ var tabs = arrayOf() @@ -93,7 +93,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) : * @param listener A [EditableListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(tab: Tab, listener: EditableListListener) { + fun bind(tab: Tab, listener: EditableListListener) { listener.bind(tab, this, dragHandle = binding.tabDragHandle) binding.tabCheckBox.apply { // Update the CheckBox name to align with the mode 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 6c9706762..e514413a4 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 @@ -25,9 +25,8 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogTabsBinding +import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD @@ -35,7 +34,8 @@ import org.oxycblt.auxio.util.logD * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration. * @author Alexander Capehart (OxygenCobalt) */ -class TabCustomizeDialog : ViewBindingDialogFragment(), EditableListListener { +class TabCustomizeDialog : + ViewBindingDialogFragment(), EditableListListener { private val tabAdapter = TabAdapter(this) private var touchHelper: ItemTouchHelper? = null @@ -46,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), Edita .setTitle(R.string.set_lib_tabs) .setPositiveButton(R.string.lbl_ok) { _, _ -> logD("Committing tab changes") - Settings(requireContext()).libTabs = tabAdapter.tabs + HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs } .setNegativeButton(R.string.lbl_cancel, null) } override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { - var tabs = Settings(requireContext()).libTabs + var tabs = HomeSettings.from(requireContext()).homeTabs // Try to restore a pending tab configuration that was saved prior. if (savedInstanceState != null) { val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS)) @@ -81,8 +81,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment(), Edita binding.tabRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Tab) { "Unexpected datatype: ${item::class.java}" } + 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 } diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt new file mode 100644 index 000000000..d5dd32dd1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.image + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD + +/** + * User configuration specific to image loading. + * @author Alexander Capehart (OxygenCobalt) + */ +interface ImageSettings : Settings { + /** The strategy to use when loading album covers. */ + val coverMode: CoverMode + + interface Listener { + /** Called when [coverMode] changes. */ + fun onCoverModeChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), ImageSettings { + override val coverMode: CoverMode + get() = + CoverMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) + ?: CoverMode.MEDIA_STORE + + override fun migrate() { + // Show album covers and Ignore MediaStore covers were unified in 3.0.0 + if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) || + sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) { + logD("Migrating cover settings") + + val mode = + when { + !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF + !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> + CoverMode.MEDIA_STORE + else -> CoverMode.QUALITY + } + + sharedPreferences.edit { + putInt(getString(R.string.set_key_cover_mode), mode.intCode) + remove(OLD_KEY_SHOW_COVERS) + remove(OLD_KEY_QUALITY_COVERS) + } + } + } + + override fun onSettingChanged(key: String, listener: Listener) { + if (key == getString(R.string.set_key_cover_mode)) { + listOf(key, listener) + } + } + + private companion object { + const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" + const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): ImageSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt index f5df8f9bb..5da781bb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt @@ -28,7 +28,7 @@ import androidx.core.widget.ImageViewCompat import com.google.android.material.shape.MaterialShapeDrawable import kotlin.math.max import org.oxycblt.auxio.R -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat @@ -52,7 +52,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val indicatorMatrix = Matrix() private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() - private val settings = Settings(context) /** * The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius @@ -62,7 +61,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr set(value) { field = value (background as? MaterialShapeDrawable)?.let { bg -> - if (settings.roundMode) { + if (UISettings.from(context).roundMode) { bg.setCornerSize(value) } else { bg.setCornerSize(0f) diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index 590404a6a..d838a7b63 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getDrawableCompat @@ -81,7 +81,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr background = MaterialShapeDrawable().apply { fillColor = context.getColorCompat(R.color.sel_cover_bg) - if (Settings(context).roundMode) { + if (UISettings.from(context).roundMode) { // Only use the specified corner radius when round mode is enabled. setCornerSize(cornerRadius) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 8c9acd412..a324690a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.library.Sort /** * A [Keyer] implementation for [Music] data. diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt index 31a4bda55..b26141f7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt @@ -29,8 +29,8 @@ import java.io.InputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.image.CoverMode +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -47,10 +47,8 @@ object Covers { * loading failed or should not occur. */ suspend fun fetch(context: Context, album: Album): InputStream? { - val settings = Settings(context) - return try { - when (settings.coverMode) { + when (ImageSettings.from(context).coverMode) { CoverMode.OFF -> null CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album) CoverMode.QUALITY -> fetchQualityCovers(context, album) 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 250992d04..dee519263 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -37,7 +37,8 @@ import org.oxycblt.auxio.util.showToast * A Fragment containing a selectable list. * @author Alexander Capehart (OxygenCobalt) */ -abstract class ListFragment : SelectionFragment(), SelectableListListener { +abstract class ListFragment : + SelectionFragment(), SelectableListListener { protected val navModel: NavigationViewModel by activityViewModels() private var currentMenu: PopupMenu? = null @@ -50,12 +51,11 @@ abstract class ListFragment : SelectionFragment(), Selecta /** * 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]. - * @param music The [Music] item that was clicked. + * @param item The [T] data of the item that was clicked. */ - abstract fun onRealClick(music: Music) + abstract fun onRealClick(item: T) - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) { if (selectionModel.selected.value.isNotEmpty()) { // Map clicking an item to selecting an item when items are already selected. selectionModel.select(item) @@ -65,8 +65,7 @@ abstract class ListFragment : SelectionFragment(), Selecta } } - override fun onSelect(item: Item) { - check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" } + override fun onSelect(item: T) { selectionModel.select(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt index c8f9ebb6d..1afa340c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt @@ -25,26 +25,22 @@ import androidx.recyclerview.widget.RecyclerView * A basic listener for list interactions. * @author Alexander Capehart (OxygenCobalt) */ -interface ClickableListListener { +interface ClickableListListener { /** - * Called when an [Item] in the list is clicked. - * @param item The [Item] that was clicked. + * Called when an item in the list is clicked. + * @param item The [T] item that was clicked. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. */ - fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) + fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. */ - fun bind( - item: Item, - viewHolder: RecyclerView.ViewHolder, - bodyView: View = viewHolder.itemView - ) { + fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) { bodyView.setOnClickListener { onClick(item, viewHolder) } } } @@ -53,7 +49,7 @@ interface ClickableListListener { * An extension of [ClickableListListener] that enables list editing functionality. * @author Alexander Capehart (OxygenCobalt) */ -interface EditableListListener : ClickableListListener { +interface EditableListListener : ClickableListListener { /** * Called when a [RecyclerView.ViewHolder] requests that it should be dragged. * @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged. @@ -62,14 +58,14 @@ interface EditableListListener : ClickableListListener { /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. * @param dragHandle A touchable [View]. Any drag on this view will start a drag event. */ fun bind( - item: Item, + item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView, dragHandle: View @@ -89,30 +85,30 @@ interface EditableListListener : ClickableListListener { * An extension of [ClickableListListener] that enables menu and selection functionality. * @author Alexander Capehart (OxygenCobalt) */ -interface SelectableListListener : ClickableListListener { +interface SelectableListListener : ClickableListListener { /** - * Called when an [Item] in the list requests that a menu related to it should be opened. - * @param item The [Item] to show a menu for. + * Called when an item in the list requests that a menu related to it should be opened. + * @param item The [T] item to open a menu for. * @param anchor The [View] to anchor the menu to. */ - fun onOpenMenu(item: Item, anchor: View) + fun onOpenMenu(item: T, anchor: View) /** - * Called when an [Item] in the list requests that it be selected. - * @param item The [Item] to select. + * Called when an item in the list requests that it be selected. + * @param item The [T] item to select. */ - fun onSelect(item: Item) + fun onSelect(item: T) /** * Binds this instance to a list item. - * @param item The [Item] that this list entry is bound to. + * @param item The [T] to bind this item to. * @param viewHolder The [RecyclerView.ViewHolder] to bind. * @param bodyView The [View] containing the main body of the list item. Any click events on * this [View] are routed to the listener. Defaults to the root view. * @param menuButton A clickable [View]. Any click events on this [View] will open a menu. */ fun bind( - item: Item, + item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView, menuButton: View diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt new file mode 100644 index 000000000..23e49344d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/DiffAdapter.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.adapter + +import androidx.recyclerview.widget.RecyclerView + +/** + * A [RecyclerView.Adapter] with [ListDiffer] integration. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. + */ +abstract class DiffAdapter( + differFactory: ListDiffer.Factory +) : RecyclerView.Adapter() { + private val differ = differFactory.new(@Suppress("LeakingThis") this) + + final override fun getItemCount() = differ.currentList.size + + /** The current list of [T] items. */ + val currentList: List + get() = differ.currentList + + /** + * Get a [T] item at the given position. + * @param at The position to get the item at. + * @throws IndexOutOfBoundsException If the index is not in the list bounds/ + */ + fun getItem(at: Int) = differ.currentList[at] + + /** + * Dynamically determine how to update the list based on the given instructions. + * @param newList The new list of [T] items to show. + * @param instructions The instructions specifying how to update the list. + * @param onDone Called when the update process is completed. Defaults to a no-op. + */ + fun submitList(newList: List, instructions: I, onDone: () -> Unit = {}) { + differ.submitList(newList, instructions, onDone) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt new file mode 100644 index 000000000..e2be45833 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.adapter + +import androidx.recyclerview.widget.AdapterListUpdateCallback +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView + +// TODO: Re-add list instructions with a less dangerous framework. + +/** + * List differ wrapper that provides more flexibility regarding the way lists are updated. + * @author Alexander Capehart (OxygenCobalt) + */ +interface ListDiffer { + /** The current list of [T] items. */ + val currentList: List + + /** + * Dynamically determine how to update the list based on the given instructions. + * @param newList The new list of [T] items to show. + * @param instructions The [BasicListInstructions] specifying how to update the list. + * @param onDone Called when the update process is completed. + */ + fun submitList(newList: List, instructions: I, onDone: () -> Unit) + + /** + * Defines the creation of new [ListDiffer] instances. Allows such [ListDiffer]s to be passed as + * arguments without reliance on a `this` [RecyclerView.Adapter]. + */ + abstract class Factory { + /** + * Create a new [ListDiffer] bound to the given [RecyclerView.Adapter]. + * @param adapter The [RecyclerView.Adapter] to bind to. + */ + abstract fun new(adapter: RecyclerView.Adapter<*>): ListDiffer + } + + /** + * Update lists on another thread. This is useful when large diffs are likely to occur in this + * list that would be exceedingly slow with [Blocking]. + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the + * internal list. + */ + class Async(private val diffCallback: DiffUtil.ItemCallback) : + Factory() { + override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = + RealAsyncListDiffer(AdapterListUpdateCallback(adapter), diffCallback) + } + + /** + * Update lists on the main thread. This is useful when many small, discrete list diffs are + * likely to occur that would cause [Async] to suffer from race conditions. + * @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the + * internal list. + */ + class Blocking(private val diffCallback: DiffUtil.ItemCallback) : + Factory() { + override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer = + RealBlockingListDiffer(AdapterListUpdateCallback(adapter), diffCallback) + } +} + +/** + * Represents the specific way to update a list of items. + * @author Alexander Capehart (OxygenCobalt) + */ +enum class BasicListInstructions { + /** + * (A)synchronously diff the list. This should be used for small diffs with little item + * movement. + */ + DIFF, + + /** + * Synchronously remove the current list and replace it with a new one. This should be used for + * large diffs with that would cause erratic scroll behavior or in-efficiency. + */ + REPLACE +} + +private abstract class BasicListDiffer : ListDiffer { + override fun submitList( + newList: List, + instructions: BasicListInstructions, + onDone: () -> Unit + ) { + when (instructions) { + BasicListInstructions.DIFF -> diffList(newList, onDone) + BasicListInstructions.REPLACE -> replaceList(newList, onDone) + } + } + + protected abstract fun diffList(newList: List, onDone: () -> Unit) + protected abstract fun replaceList(newList: List, onDone: () -> Unit) +} + +private class RealAsyncListDiffer( + updateCallback: ListUpdateCallback, + diffCallback: DiffUtil.ItemCallback +) : BasicListDiffer() { + private val inner = + AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build()) + + override val currentList: List + get() = inner.currentList + + override fun diffList(newList: List, onDone: () -> Unit) { + inner.submitList(newList, onDone) + } + + override fun replaceList(newList: List, onDone: () -> Unit) { + inner.submitList(null) { inner.submitList(newList, onDone) } + } +} + +private class RealBlockingListDiffer( + private val updateCallback: ListUpdateCallback, + private val diffCallback: DiffUtil.ItemCallback +) : BasicListDiffer() { + override var currentList = listOf() + + override fun diffList(newList: List, onDone: () -> Unit) { + if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) { + onDone() + return + } + + if (newList.isEmpty()) { + val oldListSize = currentList.size + currentList = listOf() + updateCallback.onRemoved(0, oldListSize) + onDone() + return + } + + if (currentList.isEmpty()) { + currentList = newList + updateCallback.onInserted(0, newList.size) + onDone() + return + } + + val oldList = currentList + val result = + DiffUtil.calculateDiff( + object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.areItemsTheSame(oldItem, newItem) + } else { + oldItem == null && newItem == null + } + } + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ): Boolean { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.areContentsTheSame(oldItem, newItem) + } else if (oldItem == null && newItem == null) { + true + } else { + throw AssertionError() + } + } + + override fun getChangePayload( + oldItemPosition: Int, + newItemPosition: Int + ): Any? { + val oldItem: T? = oldList[oldItemPosition] + val newItem: T? = newList[newItemPosition] + return if (oldItem != null && newItem != null) { + diffCallback.getChangePayload(oldItem, newItem) + } else { + throw AssertionError() + } + } + }) + + currentList = newList + result.dispatchUpdatesTo(updateCallback) + onDone() + } + + override fun replaceList(newList: List, onDone: () -> Unit) { + if (currentList != newList) { + diffList(listOf()) { diffList(newList, onDone) } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt similarity index 87% rename from app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt index ceb1fbf0b..588c5c9b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/PlayingIndicatorAdapter.kt @@ -15,32 +15,27 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.list.recycler +package org.oxycblt.auxio.list.adapter import android.view.View import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.util.logD /** * A [RecyclerView.Adapter] that supports indicating the playback status of a particular item. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @author Alexander Capehart (OxygenCobalt) */ -abstract class PlayingIndicatorAdapter : RecyclerView.Adapter() { +abstract class PlayingIndicatorAdapter( + differFactory: ListDiffer.Factory +) : DiffAdapter(differFactory) { // There are actually two states for this adapter: // - The currently playing item, which is usually marked as "selected" and becomes accented. // - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is // marked as "playing" or not. - private var currentItem: Item? = null + private var currentItem: T? = null private var isPlaying = false - /** - * The current list of the adapter. This is used to update items if the indicator state changes. - */ - abstract val currentList: List - - override fun getItemCount() = currentList.size - override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { // Only try to update the playing indicator if the ViewHolder supports it if (holder is ViewHolder) { @@ -55,10 +50,10 @@ abstract class PlayingIndicatorAdapter : RecyclerV } /** * Update the currently playing item in the list. - * @param item The item currently being played, or null if it is not being played. + * @param item The [T] currently being played, or null if it is not being played. * @param isPlaying Whether playback is ongoing or paused. */ - fun setPlayingItem(item: Item?, isPlaying: Boolean) { + fun setPlaying(item: T?, isPlaying: Boolean) { var updatedItem = false if (currentItem != item) { val oldItem = currentItem diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt index 64036c7cd..20239546c 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/SelectionIndicatorAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.list.recycler +package org.oxycblt.auxio.list.adapter import android.view.View import androidx.recyclerview.widget.RecyclerView @@ -24,11 +24,13 @@ import org.oxycblt.auxio.music.Music /** * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of * items. + * @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use. * @author Alexander Capehart (OxygenCobalt) */ -abstract class SelectionIndicatorAdapter : - PlayingIndicatorAdapter() { - private var selectedItems = setOf() +abstract class SelectionIndicatorAdapter( + differFactory: ListDiffer.Factory +) : PlayingIndicatorAdapter(differFactory) { + private var selectedItems = setOf() override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { super.onBindViewHolder(holder, position, payloads) @@ -39,9 +41,9 @@ abstract class SelectionIndicatorAdapter : /** * Update the list of selected items. - * @param items A list of selected [Music]. + * @param items A set of selected [T] items. */ - fun setSelectedItems(items: List) { + fun setSelected(items: Set) { val oldSelectedItems = selectedItems val newSelectedItems = items.toSet() if (newSelectedItems == oldSelectedItems) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/SimpleDiffCallback.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt rename to app/src/main/java/org/oxycblt/auxio/list/adapter/SimpleDiffCallback.kt index 9a289fc88..358c3b3f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/SimpleDiffCallback.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.list.recycler +package org.oxycblt.auxio.list.adapter import androidx.recyclerview.widget.DiffUtil import org.oxycblt.auxio.list.Item @@ -25,6 +25,6 @@ import org.oxycblt.auxio.list.Item * whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass. * @author Alexander Capehart (OxygenCobalt) */ -abstract class SimpleItemCallback : DiffUtil.ItemCallback() { +abstract class SimpleDiffCallback : DiffUtil.ItemCallback() { final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index b29e8cb7f..06eea4411 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -45,6 +45,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // Auxio's non-dialog RecyclerViews never change their size based on adapter contents, // so we can enable fixed-size optimizations. setHasFixedSize(true) + addItemDecoration(HeaderItemDecoration(context)) } final override fun setHasFixedSize(hasFixedSize: Boolean) { @@ -52,6 +53,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr super.setHasFixedSize(hasFixedSize) } + final override fun addItemDecoration(decor: ItemDecoration) { + super.addItemDecoration(decor) + } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { // Update the RecyclerView's padding such that the bottom insets are applied // while still preserving bottom padding. @@ -78,6 +83,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } } + /** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */ + /** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */ interface SpanSizeLookup { /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt new file mode 100644 index 000000000..b715b8b70 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.recycler + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.BackportMaterialDividerItemDecoration +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.Header +import org.oxycblt.auxio.list.adapter.DiffAdapter + +/** + * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly + * separate content with headers. + * @author Alexander Capehart (OxygenCobalt) + */ +class HeaderItemDecoration +@JvmOverloads +constructor( + context: Context, + attributeSet: AttributeSet? = null, + defStyleAttr: Int = R.attr.materialDividerStyle, + orientation: Int = LinearLayoutManager.VERTICAL +) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { + override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = + try { + // Add a divider if the next item is a header. This organizes the divider to separate + // the ends of content rather than the beginning of content, alongside an added benefit + // of preventing top headers from having a divider applied. + (adapter as DiffAdapter<*, *, *>).getItem(position + 1) is Header + } catch (e: ClassCastException) { + false + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt deleted file mode 100644 index 4b47c22c3..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * - * 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.recycler - -import androidx.recyclerview.widget.AdapterListUpdateCallback -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView - -/** - * A list differ that operates synchronously. This can help resolve some shortcomings with - * AsyncListDiffer, at the cost of performance. Derived from Material Files: - * https://github.com/zhanghai/MaterialFiles - * @author Hai Zhang, Alexander Capehart (OxygenCobalt) - */ -class SyncListDiffer( - adapter: RecyclerView.Adapter<*>, - private val diffCallback: DiffUtil.ItemCallback -) { - private val updateCallback = AdapterListUpdateCallback(adapter) - - var currentList: List = emptyList() - private set(newList) { - if (newList === currentList || newList.isEmpty() && currentList.isEmpty()) { - return - } - - if (newList.isEmpty()) { - val oldListSize = currentList.size - field = emptyList() - updateCallback.onRemoved(0, oldListSize) - return - } - - if (currentList.isEmpty()) { - field = newList - updateCallback.onInserted(0, newList.size) - return - } - - val oldList = currentList - val result = - DiffUtil.calculateDiff( - object : DiffUtil.Callback() { - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.areItemsTheSame(oldItem, newItem) - } else { - oldItem == null && newItem == null - } - } - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ): Boolean { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.areContentsTheSame(oldItem, newItem) - } else if (oldItem == null && newItem == null) { - true - } else { - throw AssertionError() - } - } - - override fun getChangePayload( - oldItemPosition: Int, - newItemPosition: Int - ): Any? { - val oldItem: T? = oldList[oldItemPosition] - val newItem: T? = newList[newItemPosition] - return if (oldItem != null && newItem != null) { - diffCallback.getChangePayload(oldItem, newItem) - } else { - throw AssertionError() - } - } - }) - - field = newList - result.dispatchUpdatesTo(updateCallback) - } - - /** - * Submit a list like AsyncListDiffer. This is exceedingly slow for large diffs, so only use it - * if the changes are trivial. - * @param newList The list to update to. - */ - fun submitList(newList: List) { - if (newList == currentList) { - // Nothing to do. - return - } - - currentList = newList - } - - /** - * Replace this list with a new list. This is good for large diffs that are too slow to update - * synchronously, but too chaotic to update asynchronously. - * @param newList The list to update to. - */ - fun replaceList(newList: List) { - if (newList == currentList) { - // Nothing to do. - return - } - - currentList = emptyList() - currentList = newList - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 15628c41c..d72bf6814 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -26,13 +26,13 @@ import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.SelectableListListener -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance. @@ -45,7 +45,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : * @param song The new [Song] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(song: Song, listener: SelectableListListener) { + fun bind(song: Song, listener: SelectableListListener) { listener.bind(song, this, menuButton = binding.songMenu) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) @@ -74,7 +74,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) : /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem) } @@ -92,7 +92,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding * @param album The new [Album] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(album: Album, listener: SelectableListListener) { + fun bind(album: Album, listener: SelectableListListener) { listener.bind(album, this, menuButton = binding.parentMenu) binding.parentImage.bind(album) binding.parentName.text = album.resolveName(binding.context) @@ -121,11 +121,11 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Album, newItem: Album) = oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem) && - oldItem.type == newItem.type + oldItem.releaseType == newItem.releaseType } } } @@ -141,7 +141,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin * @param artist The new [Artist] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(artist: Artist, listener: SelectableListListener) { + fun bind(artist: Artist, listener: SelectableListListener) { listener.bind(artist, this, menuButton = binding.parentMenu) binding.parentImage.bind(artist) binding.parentName.text = artist.resolveName(binding.context) @@ -180,7 +180,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem.rawName == newItem.rawName && oldItem.albums.size == newItem.albums.size && @@ -200,7 +200,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding * @param genre The new [Genre] to bind. * @param listener An [SelectableListListener] to bind interactions to. */ - fun bind(genre: Genre, listener: SelectableListListener) { + fun bind(genre: Genre, listener: SelectableListListener) { listener.bind(genre, this, menuButton = binding.parentMenu) binding.parentImage.bind(genre) binding.parentName.text = genre.resolveName(binding.context) @@ -233,7 +233,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size } @@ -251,6 +251,7 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin * @param header The new [Header] to bind. */ fun bind(header: Header) { + logD(binding.context.getString(header.titleRes)) binding.title.text = binding.context.getString(header.titleRes) } @@ -268,7 +269,7 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback

() { + object : SimpleDiffCallback
() { override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = oldItem.titleRes == newItem.titleRes } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt index a5d762c42..32edb8f7a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -71,6 +71,14 @@ abstract class SelectionFragment : requireContext().showToast(R.string.lng_queue_added) true } + R.id.action_selection_play -> { + playbackModel.play(selectionModel.consume()) + true + } + R.id.action_selection_shuffle -> { + playbackModel.shuffle(selectionModel.consume()) + true + } else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 754e8ca08..a607b9cd6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.library.Library /** * A [ViewModel] that manages the current selection. @@ -38,7 +40,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener { musicStore.addListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library == null) { return } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 095bae072..39aeab02d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music import android.content.Context import android.os.Parcelable +import androidx.annotation.VisibleForTesting import java.security.MessageDigest import java.text.CollationKey import java.text.Collator @@ -30,10 +31,12 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.filesystem.* +import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseMultiValue -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.storage.* +import org.oxycblt.auxio.music.tags.Date +import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -308,10 +311,10 @@ sealed class MusicParent : Music() { /** * A song. Perhaps the foundation of the entirety of Auxio. * @param raw The [Song.Raw] to derive the member data from. - * @param settings [Settings] to determine the artist configuration. + * @param musicSettings [MusicSettings] to perform further user-configured parsing. * @author Alexander Capehart (OxygenCobalt) */ -class Song constructor(raw: Raw, settings: Settings) : Music() { +class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) } @@ -381,9 +384,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { val album: Album get() = unlikelyToBeNull(_album) - private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings) - private val artistNames = raw.artistNames.parseMultiValue(settings) - private val artistSortNames = raw.artistSortNames.parseMultiValue(settings) + private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings) + private val artistNames = raw.artistNames.parseMultiValue(musicSettings) + private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings) private val rawArtists = artistNames.mapIndexed { i, name -> Artist.Raw( @@ -392,9 +395,10 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { artistSortNames.getOrNull(i)) } - private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings) - private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings) - private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings) + private val albumArtistMusicBrainzIds = + raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) + private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings) + private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings) private val rawAlbumArtists = albumArtistNames.mapIndexed { i, name -> Artist.Raw( @@ -462,7 +466,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, sortName = raw.albumSortName, - type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings)), + releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)), rawArtists = rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }) @@ -481,7 +485,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { */ val _rawGenres = raw.genreNames - .parseId3GenreNames(settings) + .parseId3GenreNames(musicSettings) .map { Genre.Raw(it) } .ifEmpty { listOf(Genre.Raw()) } @@ -581,8 +585,8 @@ class Song constructor(raw: Raw, settings: Settings) : Music() { var albumName: String? = null, /** @see Album.Raw.sortName */ var albumSortName: String? = null, - /** @see Album.Raw.type */ - var albumTypes: List = listOf(), + /** @see Album.Raw.releaseType */ + var releaseTypes: List = listOf(), /** @see Artist.Raw.musicBrainzId */ var artistMusicBrainzIds: List = listOf(), /** @see Artist.Raw.name */ @@ -628,10 +632,10 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( val dates = Date.Range.from(songs.mapNotNull { it.date }) /** - * The [Type] of this album, signifying the type of release it actually is. Defaults to - * [Type.Album]. + * The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to + * [ReleaseType.Album]. */ - val type = raw.type ?: Type.Album(null) + val releaseType = raw.releaseType ?: ReleaseType.Album(null) /** * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the * cost of image quality. @@ -726,201 +730,6 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( } } - /** - * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. - * - * This class is derived from the MusicBrainz Release Group Type specification. It can be found - * at: https://musicbrainz.org/doc/Release_Group/Type - * @author Alexander Capehart (OxygenCobalt) - */ - sealed class Type { - /** - * A specification of what kind of performance this release is. If null, the release is - * considered "Plain". - */ - abstract val refinement: Refinement? - - /** The string resource corresponding to the name of this release type to show in the UI. */ - abstract val stringRes: Int - - /** - * A plain album. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Album(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_album - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_album_live - Refinement.REMIX -> R.string.lbl_album_remix - } - } - - /** - * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class EP(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_ep - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_ep_live - Refinement.REMIX -> R.string.lbl_ep_remix - } - } - - /** - * A single. Usually a release consisting of 1-2 songs. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Single(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_single - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_single_live - Refinement.REMIX -> R.string.lbl_single_remix - } - } - - /** - * A compilation. Usually consists of many songs from a variety of artists. - * @param refinement A specification of what kind of performance this release is. If null, - * the release is considered "Plain". - */ - data class Compilation(override val refinement: Refinement?) : Type() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_compilation - // If present, include the refinement in the name of this release type. - Refinement.LIVE -> R.string.lbl_compilation_live - Refinement.REMIX -> R.string.lbl_compilation_remix - } - } - - /** - * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually - * visual) media. - */ - object Soundtrack : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_soundtrack - } - - /** - * A (DJ) Mix. These are usually one large track consisting of the artist playing several - * sub-tracks with smooth transitions between them. - */ - object Mix : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mix - } - - /** - * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or - * a future release. - */ - object Mixtape : Type() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mixtape - } - - /** A specification of what kind of performance a particular release is. */ - enum class Refinement { - /** A release consisting of a live performance */ - LIVE, - - /** A release consisting of another [Artist]s remix of a prior performance. */ - REMIX - } - - companion object { - /** - * Parse a [Type] from a string formatted with the MusicBrainz Release Group Type - * specification. - * @param types A list of values consisting of valid release type values. - * @return A [Type] consisting of the given types, or null if the types were not valid. - */ - fun parse(types: List): Type? { - val primary = types.getOrNull(0) ?: return null - return when { - // Primary types should be the first types in the sequence. - primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } - primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } - primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } - // The spec makes no mention of whether primary types are a pre-requisite for - // secondary types, so we assume that it's not and map oprhan secondary types - // to Album release types. - else -> types.parseSecondaryTypes(0) { Album(it) } - } - } - - /** - * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted - * with the MusicBrainz Release Group Type specification. - * @param index The index of the release type to parse. - * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. - * @return A [Type] corresponding to the secondary type found at that index. - */ - private inline fun List.parseSecondaryTypes( - index: Int, - convertRefinement: (Refinement?) -> Type - ): Type { - val secondary = getOrNull(index) - return if (secondary.equals("compilation", true)) { - // Secondary type is a compilation, actually parse the third type - // and put that into a compilation if needed. - parseSecondaryTypeImpl(getOrNull(index + 1)) { Compilation(it) } - } else { - // Secondary type is a plain value, use the original values given. - parseSecondaryTypeImpl(secondary, convertRefinement) - } - } - - /** - * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to - * any child values. - * @param type The release type value to parse. - * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding - * to the callee's context. This is used in order to handle secondary times that are - * actually [Refinement]s. - */ - private inline fun parseSecondaryTypeImpl( - type: String?, - convertRefinement: (Refinement?) -> Type - ) = - when { - // Parse all the types that have no children - type.equals("soundtrack", true) -> Soundtrack - type.equals("mixtape/street", true) -> Mixtape - type.equals("dj-mix", true) -> Mix - type.equals("live", true) -> convertRefinement(Refinement.LIVE) - type.equals("remix", true) -> convertRefinement(Refinement.REMIX) - else -> convertRefinement(null) - } - } - } - /** * Raw information about an [Album] obtained from the component [Song] instances. **This is only * meant for use within the music package.** @@ -937,8 +746,8 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( val name: String, /** @see Music.rawSortName */ val sortName: String?, - /** @see Album.type */ - val type: Type?, + /** @see Album.releaseType */ + val releaseType: ReleaseType?, /** @see Artist.Raw.name */ val rawArtists: List ) { @@ -955,16 +764,15 @@ class Album constructor(raw: Raw, override val songs: List) : MusicParent( override fun hashCode() = hashCode - override fun equals(other: Any?): Boolean { - if (other !is Raw) return false - if (musicBrainzId != null && - other.musicBrainzId != null && - musicBrainzId == other.musicBrainzId) { - return true - } - - return name.equals(other.name, true) && rawArtists == other.rawArtists - } + override fun equals(other: Any?) = + other is Raw && + when { + musicBrainzId != null && other.musicBrainzId != null -> + musicBrainzId == other.musicBrainzId + musicBrainzId == null && other.musicBrainzId == null -> + name.equals(other.name, true) && rawArtists == other.rawArtists + else -> false + } } } @@ -1108,21 +916,19 @@ class Artist constructor(private val raw: Raw, songAlbums: List) : MusicP override fun hashCode() = hashCode - override fun equals(other: Any?): Boolean { - if (other !is Raw) return false - - if (musicBrainzId != null && - other.musicBrainzId != null && - musicBrainzId == other.musicBrainzId) { - return true - } - - return when { - name != null && other.name != null -> name.equals(other.name, true) - name == null && other.name == null -> true - else -> false - } - } + override fun equals(other: Any?) = + other is Raw && + when { + musicBrainzId != null && other.musicBrainzId != null -> + musicBrainzId == other.musicBrainzId + musicBrainzId == null && other.musicBrainzId == null -> + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } + else -> false + } } } @@ -1217,7 +1023,7 @@ class Genre constructor(private val raw: Raw, override val songs: List) : * @return A [UUID] converted from the [String] value, or null if the value was not valid. * @see UUID.fromString */ -fun String.toUuidOrNull(): UUID? = +private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { @@ -1228,7 +1034,8 @@ fun String.toUuidOrNull(): UUID? = * Update a [MessageDigest] with a lowercase [String]. * @param string The [String] to hash. If null, it will not be hashed. */ -private fun MessageDigest.update(string: String?) { +@VisibleForTesting +fun MessageDigest.update(string: String?) { if (string != null) { update(string.lowercase().toByteArray()) } else { @@ -1240,7 +1047,8 @@ private fun MessageDigest.update(string: String?) { * Update a [MessageDigest] with the string representation of a [Date]. * @param date The [Date] to hash. If null, nothing will be done. */ -private fun MessageDigest.update(date: Date?) { +@VisibleForTesting +fun MessageDigest.update(date: Date?) { if (date != null) { update(date.toString().toByteArray()) } else { @@ -1252,7 +1060,8 @@ private fun MessageDigest.update(date: Date?) { * Update a [MessageDigest] with the lowercase versions of all of the input [String]s. * @param strings The [String]s to hash. If a [String] is null, it will not be hashed. */ -private fun MessageDigest.update(strings: List) { +@VisibleForTesting +fun MessageDigest.update(strings: List) { strings.forEach(::update) } @@ -1260,7 +1069,8 @@ private fun MessageDigest.update(strings: List) { * Update a [MessageDigest] with the little-endian bytes of a [Int]. * @param n The [Int] to write. If null, nothing will be done. */ -private fun MessageDigest.update(n: Int?) { +@VisibleForTesting +fun MessageDigest.update(n: Int?) { if (n != null) { update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) } else { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt new file mode 100644 index 000000000..b96a97fbd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music + +import android.content.Context +import android.os.storage.StorageManager +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.library.Sort +import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.storage.MusicDirectories +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.getSystemServiceCompat + +/** + * User configuration specific to music system. + * @author Alexander Capehart (OxygenCobalt) + */ +interface MusicSettings : Settings { + /** The configuration on how to handle particular directories in the music library. */ + var musicDirs: MusicDirectories + /** Whether to exclude non-music audio files from the music library. */ + val excludeNonMusic: Boolean + /** Whether to be actively watching for changes in the music library. */ + val shouldBeObserving: Boolean + /** A [String] of characters representing the desired characters to denote multi-value tags. */ + var multiValueSeparators: String + /** 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 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 an [Genre]'s [Song] list. */ + var genreSongSort: Sort + + interface Listener { + /** Called when a setting controlling how music is loaded has changed. */ + fun onIndexingSettingChanged() {} + /** Called when the [shouldBeObserving] configuration has changed. */ + fun onObservingChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), MusicSettings { + private val storageManager = context.getSystemServiceCompat(StorageManager::class) + + override var musicDirs: MusicDirectories + get() { + val dirs = + (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) + ?: emptySet()) + .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } + return MusicDirectories( + dirs, + sharedPreferences.getBoolean( + getString(R.string.set_key_music_dirs_include), false)) + } + set(value) { + sharedPreferences.edit { + putStringSet( + getString(R.string.set_key_music_dirs), + value.dirs.map(Directory::toDocumentTreeUri).toSet()) + putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) + apply() + } + } + + override val excludeNonMusic: Boolean + get() = + sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) + + override val shouldBeObserving: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) + + override var multiValueSeparators: String + // Differ from convention and store a string of separator characters instead of an int + // code. This makes it easier to use and more extendable. + get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: "" + set(value) { + sharedPreferences.edit { + putString(getString(R.string.set_key_separators), value) + apply() + } + } + + override var songSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByName, true) + 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, true) + 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, true) + 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, true) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_genres_sort), value.intCode) + apply() + } + } + + override var albumSongSort: Sort + get() { + var sort = + Sort.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDisc, true) + + // Correct legacy album sort modes to Disc + if (sort.mode is Sort.Mode.ByName) { + sort = sort.withMode(Sort.Mode.ByDisc) + } + + return sort + } + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_album_songs_sort), value.intCode) + apply() + } + } + + override var artistSongSort: Sort + get() = + Sort.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE)) + ?: Sort(Sort.Mode.ByDate, false) + 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, true) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_genre_songs_sort), value.intCode) + apply() + } + } + + override fun onSettingChanged(key: String, listener: Listener) { + when (key) { + getString(R.string.set_key_exclude_non_music), + getString(R.string.set_key_music_dirs), + getString(R.string.set_key_music_dirs_include), + getString(R.string.set_key_separators) -> listener.onIndexingSettingChanged() + getString(R.string.set_key_observing) -> listener.onObservingChanged() + } + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): MusicSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index aa40f8ec5..2e9bbab2d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -17,14 +17,10 @@ package org.oxycblt.auxio.music -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import org.oxycblt.auxio.music.filesystem.contentResolverSafe -import org.oxycblt.auxio.music.filesystem.useQuery +import org.oxycblt.auxio.music.library.Library /** - * A repository granting access to the music library.. + * A repository granting access to the music library. * * This can be used to obtain certain music items, or await changes to the music library. It is * generally recommended to use this over Indexer to keep track of the library state, as the @@ -62,7 +58,7 @@ class MusicStore private constructor() { } /** - * Remove a [Listener] from this instance, preventing it from recieving any further updates. + * Remove a [Listener] from this instance, preventing it from receiving any further updates. * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in * the first place. * @see Listener @@ -72,101 +68,6 @@ class MusicStore private constructor() { listeners.remove(listener) } - /** - * A library of [Music] instances. - * @param songs All [Song]s loaded from the device. - * @param albums All [Album]s that could be created. - * @param artists All [Artist]s that could be created. - * @param genres All [Genre]s that could be created. - */ - data class Library( - val songs: List, - val albums: List, - val artists: List, - val genres: List, - ) { - private val uidMap = HashMap() - - init { - // The data passed to Library initially are complete, but are still volitaile. - // Finalize them to ensure they are well-formed. Also initialize the UID map in - // the same loop for efficiency. - for (song in songs) { - song._finalize() - uidMap[song.uid] = song - } - - for (album in albums) { - album._finalize() - uidMap[album.uid] = album - } - - for (artist in artists) { - artist._finalize() - uidMap[artist.uid] = artist - } - - for (genre in genres) { - genre._finalize() - uidMap[genre.uid] = genre - } - } - - /** - * Finds a [Music] item [T] in the library by it's [Music.UID]. - * @param uid The [Music.UID] to search for. - * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found - * or the [Music.UID] did not correspond to a [T]. - */ - @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T - - /** - * Convert a [Song] from an another library into a [Song] in this [Library]. - * @param song The [Song] to convert. - * @return The analogous [Song] in this [Library], or null if it does not exist. - */ - fun sanitize(song: Song) = find(song.uid) - - /** - * Convert a [Album] from an another library into a [Album] in this [Library]. - * @param album The [Album] to convert. - * @return The analogous [Album] in this [Library], or null if it does not exist. - */ - fun sanitize(album: Album) = find(album.uid) - - /** - * Convert a [Artist] from an another library into a [Artist] in this [Library]. - * @param artist The [Artist] to convert. - * @return The analogous [Artist] in this [Library], or null if it does not exist. - */ - fun sanitize(artist: Artist) = find(artist.uid) - - /** - * Convert a [Genre] from an another library into a [Genre] in this [Library]. - * @param genre The [Genre] to convert. - * @return The analogous [Genre] in this [Library], or null if it does not exist. - */ - fun sanitize(genre: Genre) = find(genre.uid) - - /** - * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. - * @param context [Context] required to analyze the [Uri]. - * @param uri [Uri] to search for. - * @return A [Song] corresponding to the given [Uri], or null if one could not be found. - */ - fun findSongForUri(context: Context, uri: Uri) = - context.contentResolverSafe.useQuery( - uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> - cursor.moveToFirst() - // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a - // song. Do what we can to hopefully find the song the user wanted to open. - val displayName = - cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) - val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) - songs.find { it.path.name == displayName && it.size == size } - } - } - /** A listener for changes in the music library. */ interface Listener { /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt index 10dc6ed72..94531c376 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -23,10 +23,10 @@ import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull -import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.parsing.correctWhitespace import org.oxycblt.auxio.music.parsing.splitEscaped +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.* /** @@ -142,7 +142,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId rawSong.albumName = cachedRawSong.albumName rawSong.albumSortName = cachedRawSong.albumSortName - rawSong.albumTypes = cachedRawSong.albumTypes + rawSong.releaseTypes = cachedRawSong.releaseTypes rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds rawSong.artistNames = cachedRawSong.artistNames @@ -190,7 +190,7 @@ private class CacheDatabase(context: Context) : append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,") append("${Columns.ALBUM_NAME} STRING NOT NULL,") append("${Columns.ALBUM_SORT_NAME} STRING,") - append("${Columns.ALBUM_TYPES} STRING,") + append("${Columns.RELEASE_TYPES} STRING,") append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,") append("${Columns.ARTIST_NAMES} STRING,") append("${Columns.ARTIST_SORT_NAMES} STRING,") @@ -249,7 +249,7 @@ private class CacheDatabase(context: Context) : cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID) val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME) val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME) - val albumTypesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_TYPES) + val releaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.RELEASE_TYPES) val artistMusicBrainzIdsIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS) @@ -286,8 +286,8 @@ private class CacheDatabase(context: Context) : raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex) raw.albumName = cursor.getString(albumNameIndex) raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex) - cursor.getStringOrNull(albumTypesIndex)?.let { - raw.albumTypes = it.parseSQLMultiValue() + cursor.getStringOrNull(releaseTypesIndex)?.let { + raw.releaseTypes = it.parseSQLMultiValue() } cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let { @@ -351,7 +351,7 @@ private class CacheDatabase(context: Context) : put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId) put(Columns.ALBUM_NAME, rawSong.albumName) put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName) - put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue()) + put(Columns.RELEASE_TYPES, rawSong.releaseTypes.toSQLMultiValue()) put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue()) put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue()) @@ -422,8 +422,8 @@ private class CacheDatabase(context: Context) : const val ALBUM_NAME = "album" /** @see Song.Raw.albumSortName */ const val ALBUM_SORT_NAME = "album_sort" - /** @see Song.Raw.albumTypes */ - const val ALBUM_TYPES = "album_types" + /** @see Song.Raw.releaseTypes */ + const val RELEASE_TYPES = "album_types" /** @see Song.Raw.artistMusicBrainzIds */ const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid" /** @see Song.Raw.artistNames */ @@ -442,7 +442,7 @@ private class CacheDatabase(context: Context) : companion object { private const val DB_NAME = "auxio_music_cache.db" - private const val DB_VERSION = 1 + private const val DB_VERSION = 2 private const val TABLE_RAW_SONGS = "raw_songs" @Volatile private var INSTANCE: CacheDatabase? = null diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index cc65b8b7f..0145222aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -27,17 +27,17 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File -import org.oxycblt.auxio.music.Date +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.Directory -import org.oxycblt.auxio.music.filesystem.contentResolverSafe -import org.oxycblt.auxio.music.filesystem.directoryCompat -import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat -import org.oxycblt.auxio.music.filesystem.safeQuery -import org.oxycblt.auxio.music.filesystem.storageVolumesCompat -import org.oxycblt.auxio.music.filesystem.useQuery import org.oxycblt.auxio.music.parsing.parseId3v2Position -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.storage.Directory +import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.storage.directoryCompat +import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat +import org.oxycblt.auxio.music.storage.safeQuery +import org.oxycblt.auxio.music.storage.storageVolumesCompat +import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull @@ -86,20 +86,20 @@ abstract class MediaStoreExtractor( open fun init(): Cursor { val start = System.currentTimeMillis() cacheExtractor.init() - val settings = Settings(context) + val musicSettings = MusicSettings.from(context) val storageManager = context.getSystemServiceCompat(StorageManager::class) val args = mutableListOf() var selector = BASE_SELECTOR // Filter out audio that is not music, if enabled. - if (settings.excludeNonMusic) { + if (musicSettings.excludeNonMusic) { logD("Excluding non-music") selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1" } // Set up the projection to follow the music directory configuration. - val dirs = settings.getMusicDirs(storageManager) + val dirs = musicSettings.musicDirs if (dirs.dirs.isNotEmpty()) { selector += " AND " if (!dirs.shouldInclude) { @@ -305,7 +305,7 @@ abstract class MediaStoreExtractor( // MediaStore only exposes the year value of a file. This is actually worse than it // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. // This is one of the major weaknesses of using MediaStore, hence the redundancy layers. - raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from) + raw.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) // A non-existent album name should theoretically be the name of the folder it contained // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the // file is not actually in the root internal storage directory. We can't do anything to diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index 8aad9fab7..b80b45882 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -21,10 +21,11 @@ import android.content.Context import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever -import org.oxycblt.auxio.music.Date +import kotlinx.coroutines.flow.flow import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.filesystem.toAudioUri import org.oxycblt.auxio.music.parsing.parseId3v2Position +import org.oxycblt.auxio.music.storage.toAudioUri +import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -61,12 +62,11 @@ class MetadataExtractor( fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs) /** - * Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the - * sub-extractors before parsing the metadata itself. - * @param emit A listener that will be invoked with every new [Song.Raw] instance when they are - * successfully loaded. + * Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will + * first delegate to the sub-extractors before parsing the metadata itself. + * @return A flow of [Song.Raw] instances. */ - suspend fun parse(emit: suspend (Song.Raw) -> Unit) { + fun extract() = flow { while (true) { val raw = Song.Raw() when (mediaStoreExtractor.populate(raw)) { @@ -160,9 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) { val metadata = format.metadata if (metadata != null) { - val tags = Tags(metadata) - populateWithId3v2(tags.id3v2) - populateWithVorbis(tags.vorbis) + val textTags = TextTags(metadata) + populateWithId3v2(textTags.id3v2) + populateWithVorbis(textTags.vorbis) } else { logD("No metadata could be extracted for ${raw.name}") } @@ -207,18 +207,20 @@ class Task(context: Context, private val raw: Song.Raw) { textFrames["TALB"]?.let { raw.albumName = it[0] } textFrames["TSOA"]?.let { raw.albumSortName = it[0] } (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { - raw.albumTypes = it + raw.releaseTypes = it } // Artist textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } - textFrames["TPE1"]?.let { raw.artistNames = it } - textFrames["TSOP"]?.let { raw.artistSortNames = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } + (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it } // Album artist textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } - textFrames["TPE2"]?.let { raw.albumArtistNames = it } - textFrames["TSO2"]?.let { raw.albumArtistSortNames = it } + (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } + (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { + raw.albumArtistSortNames = it + } // Genre textFrames["TCON"]?.let { raw.genreNames = it } @@ -229,7 +231,7 @@ class Task(context: Context, private val raw: Song.Raw) { * Frames. * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more * values. - * @retrn A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a + * @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a * hour/minute value from TIME. No second value is included. The latter two fields may not be * included in they cannot be parsed. Will be null if a year value could not be parsed. */ @@ -292,26 +294,28 @@ class Task(context: Context, private val raw: Song.Raw) { // date tag that android supports, so it must be 15 years old or more!) (comments["originaldate"]?.run { Date.from(first()) } ?: comments["date"]?.run { Date.from(first()) } - ?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) }) + ?: comments["year"]?.run { Date.from(first()) }) ?.let { raw.date = it } // Album comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } comments["album"]?.let { raw.albumName = it[0] } comments["albumsort"]?.let { raw.albumSortName = it[0] } - comments["releasetype"]?.let { raw.albumTypes = it } + comments["releasetype"]?.let { raw.releaseTypes = it } // Artist comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } - comments["artist"]?.let { raw.artistNames = it } - comments["artistsort"]?.let { raw.artistSortNames = it } + (comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it } + (comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it } // Album artist comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } - comments["albumartist"]?.let { raw.albumArtistNames = it } - comments["albumartistsort"]?.let { raw.albumArtistSortNames = it } + (comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } + (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { + raw.albumArtistSortNames = it + } // Genre - comments["GENRE"]?.let { raw.genreNames = it } + comments["genre"]?.let { raw.genreNames = it } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt rename to app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt index 03179a230..493a3421e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt @@ -24,11 +24,11 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment import org.oxycblt.auxio.music.parsing.correctWhitespace /** - * Processing wrapper for [Metadata] that allows access to more organized music tags. + * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. * @param metadata The [Metadata] to wrap. * @author Alexander Capehart (OxygenCobalt) */ -class Tags(metadata: Metadata) { +class TextTags(metadata: Metadata) { private val _id3v2 = mutableMapOf>() /** The ID3v2 text identification frames found in the file. Can have more than one value. */ val id3v2: Map> @@ -65,6 +65,10 @@ class Tags(metadata: Metadata) { is VorbisComment -> { // Vorbis comment keys can be in any case, make them uppercase for simplicity. val id = tag.key.sanitize().lowercase() + if (id == "metadata_block_picture") { + // Picture, we don't care about these + continue + } val value = tag.value.sanitize().correctWhitespace() if (value != null) { _vorbis.getOrPut(id) { mutableListOf() }.add(value) diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt new file mode 100644 index 000000000..d8a42b40a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.library + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.storage.contentResolverSafe +import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.util.logD + +/** + * Organized music library information. + * + * This class allows for the creation of a well-formed music library graph from raw song + * information. It's generally not expected to create this yourself and instead use [MusicStore]. + * + * @author Alexander Capehart + */ +class Library(rawSongs: List, settings: MusicSettings) { + /** All [Song]s that were detected on the device. */ + val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct()) + /** All [Album]s found on the device. */ + val albums = buildAlbums(songs) + /** All [Artist]s found on the device. */ + val artists = buildArtists(songs, albums) + /** All [Genre]s found on the device. */ + val genres = buildGenres(songs) + + // Use a mapping to make finding information based on it's UID much faster. + private val uidMap = buildMap { + for (music in (songs + albums + artists + genres)) { + // Finalize all music in the same mapping creation loop for efficiency. + music._finalize() + this[music.uid] = music + } + } + + /** + * Finds a [Music] item [T] in the library by it's [Music.UID]. + * @param uid The [Music.UID] to search for. + * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or + * the [Music.UID] did not correspond to a [T]. + */ + @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T + + /** + * Convert a [Song] from an another library into a [Song] in this [Library]. + * @param song The [Song] to convert. + * @return The analogous [Song] in this [Library], or null if it does not exist. + */ + fun sanitize(song: Song) = find(song.uid) + + /** + * Convert a [Album] from an another library into a [Album] in this [Library]. + * @param album The [Album] to convert. + * @return The analogous [Album] in this [Library], or null if it does not exist. + */ + fun sanitize(album: Album) = find(album.uid) + + /** + * Convert a [Artist] from an another library into a [Artist] in this [Library]. + * @param artist The [Artist] to convert. + * @return The analogous [Artist] in this [Library], or null if it does not exist. + */ + fun sanitize(artist: Artist) = find(artist.uid) + + /** + * Convert a [Genre] from an another library into a [Genre] in this [Library]. + * @param genre The [Genre] to convert. + * @return The analogous [Genre] in this [Library], or null if it does not exist. + */ + fun sanitize(genre: Genre) = find(genre.uid) + + /** + * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. + * @param context [Context] required to analyze the [Uri]. + * @param uri [Uri] to search for. + * @return A [Song] corresponding to the given [Uri], or null if one could not be found. + */ + fun findSongForUri(context: Context, uri: Uri) = + context.contentResolverSafe.useQuery( + uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> + cursor.moveToFirst() + // We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a + // song. Do what we can to hopefully find the song the user wanted to open. + val displayName = + cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + songs.find { it.path.name == displayName && it.size == size } + } + + /** + * Build a list of [Album]s from the given [Song]s. + * @param songs The [Song]s to build [Album]s from. These will be linked with their respective + * [Album]s when created. + * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked + * with parent [Artist] instances in order to be usable. + */ + private fun buildAlbums(songs: List): List { + // Group songs by their singular raw album, then map the raw instances and their + // grouped songs to Album values. Album.Raw will handle the actual grouping rules. + val songsByAlbum = songs.groupBy { it._rawAlbum } + val albums = songsByAlbum.map { Album(it.key, it.value) } + logD("Successfully built ${albums.size} albums") + return albums + } + + /** + * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as + * they group into [Artist] instances much differently, with [Song]s being grouped primarily by + * artist names, and [Album]s being grouped primarily by album artist names. + * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of + * one or more [Artist] instances. These will be linked with their respective [Artist]s when + * created. + * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of + * one or more [Artist] instances. These will be linked with their respective [Artist]s when + * created. + * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings + * of [Song]s and [Album]s. + */ + private fun buildArtists(songs: List, albums: List): List { + // Add every raw artist credited to each Song/Album to the grouping. This way, + // different multi-artist combinations are not treated as different artists. + val musicByArtist = mutableMapOf>() + + for (song in songs) { + for (rawArtist in song._rawArtists) { + musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) + } + } + + for (album in albums) { + for (rawArtist in album._rawArtists) { + musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) + } + } + + // Convert the combined mapping into artist instances. + val artists = musicByArtist.map { Artist(it.key, it.value) } + logD("Successfully built ${artists.size} artists") + return artists + } + + /** + * Group up [Song]s into [Genre] instances. + * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of + * one or more [Genre] instances. These will be linked with their respective [Genre]s when + * created. + * @return A non-empty list of [Genre]s. + */ + private fun buildGenres(songs: List): List { + // Add every raw genre credited to each Song to the grouping. This way, + // different multi-genre combinations are not treated as different genres. + val songsByGenre = mutableMapOf>() + for (song in songs) { + for (rawGenre in song._rawGenres) { + songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) + } + } + + // Convert the mapping into genre instances. + val genres = songsByGenre.map { Genre(it.key, it.value) } + logD("Successfully built ${genres.size} genres") + return genres + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/Sort.kt rename to app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt index 5c8fa818f..2cda0e76f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt @@ -15,13 +15,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.library import androidx.annotation.IdRes import kotlin.math.max import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Sort.Mode +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Sort.Mode +import org.oxycblt.auxio.music.tags.Date /** * A sorting method. @@ -95,7 +97,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) { * Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration. * @param songs The [Song]s to sort. */ - fun songsInPlace(songs: MutableList) { + private fun songsInPlace(songs: MutableList) { songs.sortWith(mode.getSongComparator(isAscending)) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt index 95f193971..658b1cbea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt @@ -17,7 +17,7 @@ package org.oxycblt.auxio.music.parsing -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.util.nonZeroOrNull /// --- GENERIC PARSING --- @@ -26,10 +26,10 @@ import org.oxycblt.auxio.util.nonZeroOrNull * Parse a multi-value tag based on the user configuration. If the value is already composed of more * than one value, nothing is done. Otherwise, this function will attempt to split it based on the * user's separator preferences. - * @param settings [Settings] required to obtain user separator configuration. + * @param settings [MusicSettings] required to obtain user separator configuration. * @return A new list of one or more [String]s. */ -fun List.parseMultiValue(settings: Settings) = +fun List.parseMultiValue(settings: MusicSettings) = if (size == 1) { first().maybeParseBySeparators(settings) } else { @@ -99,10 +99,9 @@ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } * @param settings [Settings] required to obtain user separator configuration. * @return A list of one or more [String]s that were split up by the user-defined separators. */ -private fun String.maybeParseBySeparators(settings: Settings): List { +private fun String.maybeParseBySeparators(settings: MusicSettings): List { // Get the separators the user desires. If null, there's nothing to do. - val separators = settings.musicSeparators ?: return listOf(this) - return splitEscaped { separators.contains(it) }.correctWhitespace() + return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace() } /// --- ID3v2 PARSING --- @@ -119,10 +118,10 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * representations of genre fields into their named counterparts, and split up singular ID3v2-style * integer genre fields into one or more genres. - * @param settings [Settings] required to obtain user separator configuration. + * @param settings [MusicSettings] required to obtain user separator configuration. * @return A list of one or more genre names.. */ -fun List.parseId3GenreNames(settings: Settings) = +fun List.parseId3GenreNames(settings: MusicSettings) = if (size == 1) { first().parseId3MultiValueGenre(settings) } else { @@ -132,9 +131,10 @@ fun List.parseId3GenreNames(settings: Settings) = /** * Parse a single ID3v1/ID3v2 integer genre field into their named representations. + * @param settings [MusicSettings] required to obtain user separator configuration. * @return A list of one or more genre names. */ -private fun String.parseId3MultiValueGenre(settings: Settings) = +private fun String.parseId3MultiValueGenre(settings: MusicSettings) = parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings) /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt index de4802a25..6289ddc43 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt @@ -25,7 +25,7 @@ import com.google.android.material.checkbox.MaterialCheckBox import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment /** @@ -42,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_separators) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - Settings(requireContext()).musicSeparators = getCurrentSeparators() + MusicSettings.from(requireContext()).multiValueSeparators = getCurrentSeparators() } } @@ -59,8 +59,8 @@ class SeparatorsDialog : ViewBindingDialogFragment() { // the corresponding CheckBox for each character instead of doing an iteration // through the separator list for each CheckBox. (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) - ?: Settings(requireContext()).musicSeparators) - ?.forEach { + ?: MusicSettings.from(requireContext()).multiValueSeparators) + .forEach { when (it) { Separators.COMMA -> binding.separatorComma.isChecked = true Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt index 71bdc09b4..857a55a1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author OxygenCobalt. */ -class ArtistChoiceAdapter(private val listener: ClickableListListener) : +class ArtistChoiceAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { private var artists = listOf() @@ -67,7 +67,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @param artist The new [Artist] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(artist: Artist, listener: ClickableListListener) { + fun bind(artist: Artist, listener: ClickableListListener) { listener.bind(artist, this) binding.pickerImage.bind(artist) binding.pickerName.text = artist.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt index bebfd66da..99be53312 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt @@ -22,7 +22,6 @@ import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.NavigationViewModel @@ -41,9 +40,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() { super.onBindingCreated(binding, savedInstanceState) } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { super.onClick(item, viewHolder) - check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } // User made a choice, navigate to it. navModel.exploreNavigateTo(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt index 0bf780537..0e537e3ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt @@ -26,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.collectImmediately @@ -38,7 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately * @author Alexander Capehart (OxygenCobalt) */ abstract class ArtistPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { protected val pickerModel: PickerViewModel by viewModels() // Okay to leak this since the Listener will not be called until after initialization. private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this) @@ -68,7 +67,7 @@ abstract class ArtistPickerDialog : binding.pickerRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { findNavController().navigateUp() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt index 24ed8af43..186404a9d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt @@ -21,11 +21,12 @@ import android.os.Bundle import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.databinding.DialogMusicPickerBinding -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.androidActivityViewModels +import org.oxycblt.auxio.util.requireIs +import org.oxycblt.auxio.util.unlikelyToBeNull /** * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous. @@ -42,12 +43,10 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() { super.onBindingCreated(binding, savedInstanceState) } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) { super.onClick(item, viewHolder) // User made a choice, play the given song from that artist. - check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" } - val song = pickerModel.currentItem.value - check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" } + val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) playbackModel.playFromArtist(song, item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt index 49b5c758a..b2ddef425 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author OxygenCobalt. */ -class GenreChoiceAdapter(private val listener: ClickableListListener) : +class GenreChoiceAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { private var genres = listOf() @@ -67,7 +67,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) : * @param genre The new [Genre] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(genre: Genre, listener: ClickableListListener) { + fun bind(genre: Genre, listener: ClickableListListener) { listener.bind(genre, this) binding.pickerImage.bind(genre) binding.pickerName.text = genre.resolveName(binding.context) diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt index 0a197ab0e..dc7b3d6af 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt @@ -27,20 +27,21 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.requireIs +import org.oxycblt.auxio.util.unlikelyToBeNull /** * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. * @author Alexander Capehart (OxygenCobalt) */ class GenrePlaybackPickerDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { private val pickerModel: PickerViewModel by viewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels() // Information about what Song to show choices for is initially within the navigation arguments @@ -75,11 +76,9 @@ class GenrePlaybackPickerDialog : binding.pickerRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) { // User made a choice, play the given song from that genre. - check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" } - val song = pickerModel.currentItem.value - check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" } + val song = requireIs(unlikelyToBeNull(pickerModel.currentItem.value)) playbackModel.playFromGenre(song, item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt index 0050a8bae..c92334228 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.MusicStore +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -50,7 +52,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener { musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { refreshChoices() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt index 5531d08ca..dcdedc0ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt index b48e19c11..5536e46df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.content.Context import android.media.MediaFormat @@ -129,7 +129,6 @@ class Directory private constructor(val volume: StorageVolume, val relativePath: * @author Alexander Capehart (OxygenCobalt) */ data class MusicDirectories(val dirs: List, val shouldInclude: Boolean) -// TODO: Unify include + exclude /** * A mime type of a file. Only intended for display. diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index 441cb7cbe..3db2c0dff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -15,8 +15,9 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage +import android.content.ActivityNotFoundException import android.net.Uri import android.os.Bundle import android.os.storage.StorageManager @@ -25,11 +26,12 @@ import android.view.LayoutInflater import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.core.view.ViewCompat import androidx.core.view.isVisible import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -49,20 +51,15 @@ class MusicDirsDialog : DialogMusicDirsBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { - // Don't set the click listener here, we do some custom magic in onCreateView instead. builder .setTitle(R.string.set_dirs) - .setNeutralButton(R.string.lbl_add, null) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - val settings = Settings(requireContext()) - val dirs = - settings.getMusicDirs( - requireNotNull(storageManager) { "StorageManager was not available" }) + val settings = MusicSettings.from(requireContext()) val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding())) - if (dirs != newDirs) { + if (settings.musicDirs != newDirs) { logD("Committing changes") - settings.setMusicDirs(newDirs) + settings.musicDirs = newDirs } } } @@ -76,18 +73,21 @@ class MusicDirsDialog : registerForActivityResult( ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs) - // Now that the dialog exists, we get the view manually when the dialog is shown - // and override its click listener so that the dialog does not auto-dismiss when we - // click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former - // and the app from crashing in the latter. - requireDialog().setOnShowListener { - val dialog = it as AlertDialog - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + binding.dirsAdd.apply { + ViewCompat.setTooltipText(this, contentDescription) + setOnClickListener { logD("Opening launcher") - requireNotNull(openDocumentTreeLauncher) { + val launcher = + requireNotNull(openDocumentTreeLauncher) { "Document tree launcher was not available" } - .launch(null) + + try { + launcher.launch(null) + } catch (e: ActivityNotFoundException) { + // User doesn't have a capable file manager. + requireContext().showToast(R.string.err_no_app) + } } } @@ -96,8 +96,7 @@ class MusicDirsDialog : itemAnimator = null } - var dirs = Settings(context).getMusicDirs(storageManager) - + var dirs = MusicSettings.from(context).musicDirs if (savedInstanceState != null) { val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) if (pendingDirs != null) { @@ -178,8 +177,12 @@ class MusicDirsDialog : private fun updateMode() { val binding = requireBinding() if (isUiModeInclude(binding)) { + binding.dirsModeExclude.icon = null + binding.dirsModeInclude.setIconResource(R.drawable.ic_check_24) binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc) } else { + binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24) + binding.dirsModeInclude.icon = null binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt index 8302aaf24..6de6c4b3f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.filesystem +package org.oxycblt.auxio.music.storage import android.annotation.SuppressLint import android.content.ContentResolver @@ -196,7 +196,7 @@ val StorageVolume.isInternalCompat: Boolean get() = isPrimaryCompat && isEmulatedCompat /** - * The unique identifier for this [StorageVolume], obtained in a version compatible manner Can be + * The unique identifier for this [StorageVolume], obtained in a version compatible manner. Can be * null. * @see StorageVolume.getUuid */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 1557ec4a8..45215a8d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -27,15 +27,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig -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.MusicStore -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.Sort +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.extractor.* -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -51,7 +45,7 @@ import org.oxycblt.auxio.util.logW * @author Alexander Capehart (OxygenCobalt) */ class Indexer private constructor() { - @Volatile private var lastResponse: Result? = null + @Volatile private var lastResponse: Result? = null @Volatile private var indexingState: Indexing? = null @Volatile private var controller: Controller? = null @Volatile private var listener: Listener? = null @@ -197,11 +191,11 @@ class Indexer private constructor() { * @param context [Context] required to load music. * @param withCache Whether to use the cache or not when loading. If false, the cache will still * be written, but no cache entries will be loaded into the new library. - * @return A newly-loaded [MusicStore.Library]. + * @return A newly-loaded [Library]. * @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted. * @throws NoMusicException If no music was found on the device. */ - private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library { + private suspend fun indexImpl(context: Context, withCache: Boolean): Library { if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) == PackageManager.PERMISSION_DENIED) { // No permissions, signal that we can't do anything. @@ -217,7 +211,6 @@ class Indexer private constructor() { } else { WriteOnlyCacheExtractor(context) } - val mediaStoreExtractor = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> @@ -226,33 +219,24 @@ class Indexer private constructor() { Api29MediaStoreExtractor(context, cacheDatabase) else -> Api21MediaStoreExtractor(context, cacheDatabase) } - val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor) - - val songs = - buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() } + val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() } // Build the rest of the music library from the song list. This is much more powerful // and reliable compared to using MediaStore to obtain grouping information. val buildStart = System.currentTimeMillis() - val albums = buildAlbums(songs) - val artists = buildArtists(songs, albums) - val genres = buildGenres(songs) + val library = Library(rawSongs, MusicSettings.from(context)) logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") - return MusicStore.Library(songs, albums, artists, genres) + return library } /** * Load a list of [Song]s from the device. * @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw] * instances. - * @param settings [Settings] required to create [Song] instances. * @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked * with parent [Album], [Artist], and [Genre] items in order to be usable. */ - private suspend fun buildSongs( - metadataExtractor: MetadataExtractor, - settings: Settings - ): List { + private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List { logD("Starting indexing process") val start = System.currentTimeMillis() // Start initializing the extractors. Use an indeterminate state, as there is no ETA on @@ -262,104 +246,23 @@ class Indexer private constructor() { yield() // Note: We use a set here so we can eliminate song duplicates. - val songs = mutableSetOf() val rawSongs = mutableListOf() - metadataExtractor.parse { rawSong -> - songs.add(Song(rawSong, settings)) + metadataExtractor.extract().collect { rawSong -> rawSongs.add(rawSong) - // Now we can signal a defined progress by showing how many songs we have // loaded, and the projected amount of songs we found in the library // (obtained by the extractors) yield() - emitIndexing(Indexing.Songs(songs.size, total)) + emitIndexing(Indexing.Songs(rawSongs.size, total)) } // Finalize the extractors with the songs we have now loaded. There is no ETA // on this process, so go back to an indeterminate state. emitIndexing(Indexing.Indeterminate) metadataExtractor.finalize(rawSongs) - logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms") - - // Ensure that sorting order is consistent so that grouping is also consistent. - // Rolling this into the set is not an option, as songs with the same sort result - // would be lost. - return Sort(Sort.Mode.ByName, true).songs(songs) - } - - /** - * Build a list of [Album]s from the given [Song]s. - * @param songs The [Song]s to build [Album]s from. These will be linked with their respective - * [Album]s when created. - * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked - * with parent [Artist] instances in order to be usable. - */ - private fun buildAlbums(songs: List): List { - // Group songs by their singular raw album, then map the raw instances and their - // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val songsByAlbum = songs.groupBy { it._rawAlbum } - val albums = songsByAlbum.map { Album(it.key, it.value) } - logD("Successfully built ${albums.size} albums") - return albums - } - - /** - * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as - * they group into [Artist] instances much differently, with [Song]s being grouped primarily by - * artist names, and [Album]s being grouped primarily by album artist names. - * @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of - * one or more [Artist] instances. These will be linked with their respective [Artist]s when - * created. - * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings - * of [Song]s and [Album]s. - */ - private fun buildArtists(songs: List, albums: List): List { - // Add every raw artist credited to each Song/Album to the grouping. This way, - // different multi-artist combinations are not treated as different artists. - val musicByArtist = mutableMapOf>() - - for (song in songs) { - for (rawArtist in song._rawArtists) { - musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) - } - } - - for (album in albums) { - for (rawArtist in album._rawArtists) { - musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) - } - } - - // Convert the combined mapping into artist instances. - val artists = musicByArtist.map { Artist(it.key, it.value) } - logD("Successfully built ${artists.size} artists") - return artists - } - - /** - * Group up [Song]s into [Genre] instances. - * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of - * one or more [Genre] instances. These will be linked with their respective [Genre]s when - * created. - * @return A non-empty list of [Genre]s. - */ - private fun buildGenres(songs: List): List { - // Add every raw genre credited to each Song to the grouping. This way, - // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() - for (song in songs) { - for (rawGenre in song._rawGenres) { - songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) - } - } - - // Convert the mapping into genre instances. - val genres = songsByGenre.map { Genre(it.key, it.value) } - logD("Successfully built ${genres.size} genres") - return genres + logD( + "Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms") + return rawSongs } /** @@ -386,7 +289,7 @@ class Indexer private constructor() { * @param result The new [Result] to emit, representing the outcome of the music loading * process. */ - private suspend fun emitCompletion(result: Result) { + private suspend fun emitCompletion(result: Result) { yield() // Swap to the Main thread so that downstream callbacks don't crash from being on // a background thread. Does not occur in emitIndexing due to efficiency reasons. @@ -417,7 +320,7 @@ class Indexer private constructor() { * Music loading has completed. * @param result The outcome of the music loading process. */ - data class Complete(val result: Result) : State() + data class Complete(val result: Result) : State() } /** @@ -455,7 +358,7 @@ class Indexer private constructor() { * * This is only useful for code that absolutely must show the current loading process. * Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of - * the [MusicStore.Library]. + * the [Library]. */ interface Listener { /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 2f89bbe3b..6358cb9ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.system import android.app.Service import android.content.Intent -import android.content.SharedPreferences import android.database.ContentObserver import android.os.Handler import android.os.IBinder @@ -32,12 +31,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.filesystem.contentResolverSafe +import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD @@ -55,8 +53,7 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class IndexerService : - Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener { +class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { private val indexer = Indexer.getInstance() private val musicStore = MusicStore.getInstance() private val playbackManager = PlaybackStateManager.getInstance() @@ -68,7 +65,7 @@ class IndexerService : private lateinit var observingNotification: ObservingNotification private lateinit var wakeLock: PowerManager.WakeLock private lateinit var indexerContentObserver: SystemContentObserver - private lateinit var settings: Settings + private lateinit var settings: MusicSettings override fun onCreate() { super.onCreate() @@ -83,8 +80,8 @@ class IndexerService : // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() - settings = Settings(this) - settings.addListener(this) + settings = MusicSettings.from(this) + settings.registerListener(this) indexer.registerController(this) // An indeterminate indexer and a missing library implies we are extremely early // in app initialization so start loading music. @@ -108,7 +105,7 @@ class IndexerService : // Then cancel the listener-dependent components to ensure that stray reloading // events will not occur. indexerContentObserver.release() - settings.removeListener(this) + settings.unregisterListener(this) indexer.unregisterController(this) // Then cancel any remaining music loading jobs. serviceJob.cancel() @@ -230,22 +227,18 @@ class IndexerService : // --- SETTING CALLBACKS --- - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - // Hook changes in music settings to a new music loading event. - getString(R.string.set_key_exclude_non_music), - getString(R.string.set_key_music_dirs), - getString(R.string.set_key_music_dirs_include), - getString(R.string.set_key_separators) -> onStartIndexing(true) - getString(R.string.set_key_observing) -> { - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (!indexer.isIndexing) { - updateIdleSession() - } - } + override fun onIndexingSettingChanged() { + // Music loading configuration changed, need to reload music. + onStartIndexing(true) + } + + override fun onObservingChanged() { + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (!indexer.isIndexing) { + updateIdleSession() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt similarity index 89% rename from app/src/main/java/org/oxycblt/auxio/music/Date.kt rename to app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt index f6f7b1221..d3658ce6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music +package org.oxycblt.auxio.music.tags import android.content.Context import java.text.ParseException @@ -74,7 +74,7 @@ class Date private constructor(private val tokens: List) : Comparable override fun hashCode() = tokens.hashCode() - override fun equals(other: Any?) = other is Date && tokens == other.tokens + override fun equals(other: Any?) = other is Date && compareTo(other) == 0 override fun compareTo(other: Date): Int { for (i in 0 until max(tokens.size, other.tokens.size)) { @@ -140,8 +140,7 @@ class Date private constructor(private val tokens: List) : Comparable min.resolveDate(context) } - override fun equals(other: Any?) = - other is Range && min == other.min && max == other.max + override fun equals(other: Any?) = other is Range && min == other.min && max == other.max override fun hashCode() = 31 * max.hashCode() + min.hashCode() @@ -183,14 +182,25 @@ class Date private constructor(private val tokens: List) : Comparable */ private val ISO8601_REGEX = Regex( - """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") + """^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") /** * Create a [Date] from a year component. * @param year The year component. * @return A new [Date] of the given component, or null if the component is invalid. */ - fun from(year: Int) = fromTokens(listOf(year)) + fun from(year: Int) = + if (year in 10000000..100000000) { + // Year is actually more likely to be a separated date timestamp. Interpret + // it as such. + val stringYear = year.toString() + from( + stringYear.substring(0..3).toInt(), + stringYear.substring(4..5).toInt(), + stringYear.substring(6..7).toInt()) + } else { + fromTokens(listOf(year)) + } /** * Create a [Date] from a date component. @@ -223,8 +233,10 @@ class Date private constructor(private val tokens: List) : Comparable */ fun from(timestamp: String): Date? { val tokens = - // Match the input with the timestamp regex - (ISO8601_REGEX.matchEntire(timestamp) ?: return null) + // Match the input with the timestamp regex. If there is no match, see if we can + // fall back to some kind of year value. + (ISO8601_REGEX.matchEntire(timestamp) + ?: return timestamp.toIntOrNull()?.let(Companion::from)) .groupValues // Filter to the specific tokens we want and convert them to integer tokens. .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } @@ -239,7 +251,7 @@ class Date private constructor(private val tokens: List) : Comparable */ private fun fromTokens(tokens: List): Date? { val validated = mutableListOf() - validateTokens(tokens, validated) + transformTokens(tokens, validated) if (validated.isEmpty()) { // No token was valid, return null. return null @@ -253,7 +265,7 @@ class Date private constructor(private val tokens: List) : Comparable * @param src The input tokens to validate. * @param dst The destination list to add valid tokens to. */ - private fun validateTokens(src: List, dst: MutableList) { + private fun transformTokens(src: List, dst: MutableList) { dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return) diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt new file mode 100644 index 000000000..3331fda7a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.tags + +import org.oxycblt.auxio.R + +/** + * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. + * + * This class is derived from the MusicBrainz Release Group Type specification. It can be found at: + * https://musicbrainz.org/doc/Release_Group/Type + * @author Alexander Capehart (OxygenCobalt) + */ +sealed class ReleaseType { + /** + * A specification of what kind of performance this release is. If null, the release is + * considered "Plain". + */ + abstract val refinement: Refinement? + + /** The string resource corresponding to the name of this release type to show in the UI. */ + abstract val stringRes: Int + + /** + * A plain album. + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". + */ + data class Album(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_album + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_album_live + Refinement.REMIX -> R.string.lbl_album_remix + } + } + + /** + * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". + */ + data class EP(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_ep + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_ep_live + Refinement.REMIX -> R.string.lbl_ep_remix + } + } + + /** + * A single. Usually a release consisting of 1-2 songs. + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". + */ + data class Single(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_single + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_single_live + Refinement.REMIX -> R.string.lbl_single_remix + } + } + + /** + * A compilation. Usually consists of many songs from a variety of artists. + * @param refinement A specification of what kind of performance this release is. If null, the + * release is considered "Plain". + */ + data class Compilation(override val refinement: Refinement?) : ReleaseType() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_compilation + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_compilation_live + Refinement.REMIX -> R.string.lbl_compilation_remix + } + } + + /** + * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually + * visual) media. + */ + object Soundtrack : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_soundtrack + } + + /** + * A (DJ) Mix. These are usually one large track consisting of the artist playing several + * sub-tracks with smooth transitions between them. + */ + object Mix : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mix + } + + /** + * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or a + * future release. + */ + object Mixtape : ReleaseType() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mixtape + } + + /** A specification of what kind of performance a particular release is. */ + enum class Refinement { + /** A release consisting of a live performance */ + LIVE, + + /** A release consisting of another [Artist]s remix of a prior performance. */ + REMIX + } + + companion object { + /** + * Parse a [ReleaseType] from a string formatted with the MusicBrainz Release Group Type + * specification. + * @param types A list of values consisting of valid release type values. + * @return A [ReleaseType] consisting of the given types, or null if the types were not + * valid. + */ + fun parse(types: List): ReleaseType? { + val primary = types.getOrNull(0) ?: return null + return when { + // Primary types should be the first types in the sequence. + primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } + primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } + primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } + // The spec makes no mention of whether primary types are a pre-requisite for + // secondary types, so we assume that it's not and map oprhan secondary types + // to Album release types. + else -> types.parseSecondaryTypes(0) { Album(it) } + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted with + * the MusicBrainz Release Group Type specification. + * @param index The index of the release type to parse. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] + * corresponding to the callee's context. This is used in order to handle secondary times + * that are actually [Refinement]s. + * @return A [ReleaseType] corresponding to the secondary type found at that index. + */ + private inline fun List.parseSecondaryTypes( + index: Int, + convertRefinement: (Refinement?) -> ReleaseType + ): ReleaseType { + val secondary = getOrNull(index) + return if (secondary.equals("compilation", true)) { + // Secondary type is a compilation, actually parse the third type + // and put that into a compilation if needed. + parseSecondaryTypeImpl(getOrNull(index + 1)) { Compilation(it) } + } else { + // Secondary type is a plain value, use the original values given. + parseSecondaryTypeImpl(secondary, convertRefinement) + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to any + * child values. + * @param type The release type value to parse. + * @param convertRefinement Code to convert a [Refinement] into a [ReleaseType] + * corresponding to the callee's context. This is used in order to handle secondary times + * that are actually [Refinement]s. + */ + private inline fun parseSecondaryTypeImpl( + type: String?, + convertRefinement: (Refinement?) -> ReleaseType + ) = + when { + // Parse all the types that have no children + type.equals("soundtrack", true) -> Soundtrack + type.equals("mixtape/street", true) -> Mixtape + type.equals("dj-mix", true) -> Mix + type.equals("live", true) -> convertRefinement(Refinement.LIVE) + type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + else -> convertRefinement(null) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index af4ad3af3..5addedb70 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -24,7 +24,6 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment @@ -65,8 +64,8 @@ class PlaybackBarFragment : ViewBindingFragment() { binding.playbackInfo.isSelected = true // Set up actions - binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } - setupSecondaryActions(binding, Settings(context)) + binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() } + setupSecondaryActions(binding, playbackModel.currentBarAction) // Load the track color in manually as it's unclear whether the track actually supports // using a ColorStateList in the resources. @@ -86,8 +85,8 @@ class PlaybackBarFragment : ViewBindingFragment() { binding.playbackInfo.isSelected = false } - private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) { - when (settings.playbackBarAction) { + private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) { + when (actionMode) { ActionMode.NEXT -> { binding.playbackSecondaryAction.apply { setIconResource(R.drawable.ic_skip_next_24) @@ -109,7 +108,7 @@ class PlaybackBarFragment : ViewBindingFragment() { setIconResource(R.drawable.sel_shuffle_state_24) contentDescription = getString(R.string.desc_shuffle) iconTint = context.getColorCompat(R.color.sel_activatable_icon) - setOnClickListener { playbackModel.invertShuffled() } + setOnClickListener { playbackModel.toggleShuffled() } collectImmediately(playbackModel.isShuffled, ::updateShuffled) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index f29391284..d5450722d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -105,9 +105,9 @@ class PlaybackPanelFragment : // TODO: Add better playback button accessibility binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() } binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() } - binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() } + binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() } binding.playbackSkipNext.setOnClickListener { playbackModel.next() } - binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() } + binding.playbackShuffle.setOnClickListener { playbackModel.toggleShuffled() } // --- VIEWMODEL SETUP -- collectImmediately(playbackModel.song, ::updateSong) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt new file mode 100644 index 000000000..8c3d36b7a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.playback.replaygain.ReplayGainMode +import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.util.logD + +/** + * User configuration specific to the playback system. + * @author Alexander Capehart (OxygenCobalt) + */ +interface PlaybackSettings : Settings { + /** The action to display on the playback bar. */ + val barAction: ActionMode + /** The action to display in the playback notification. */ + val notificationAction: ActionMode + /** Whether to start playback when a headset is plugged in. */ + val headsetAutoplay: Boolean + /** The current ReplayGain configuration. */ + val replayGainMode: ReplayGainMode + /** The current ReplayGain pre-amp configuration. */ + var replayGainPreAmp: ReplayGainPreAmp + /** + * What type of MusicParent to play from when a Song is played from a list of other items. Null + * if to play from all Songs. + */ + val inListPlaybackMode: MusicMode + /** + * What type of MusicParent to play from when a Song is played from within an item (ex. like in + * the detail view). Null if to play from the item it was played in. + */ + val inParentPlaybackMode: MusicMode? + /** Whether to keep shuffle on when playing a new Song. */ + val keepShuffle: Boolean + /** Whether to rewind when the skip previous button is pressed before skipping back. */ + val rewindWithPrev: Boolean + /** Whether a song should pause after every repeat. */ + val pauseOnRepeat: Boolean + + interface Listener { + /** Called when one of the ReplayGain configurations have changed. */ + fun onReplayGainSettingsChanged() {} + /** Called when [notificationAction] has changed. */ + fun onNotificationActionChanged() {} + } + + private class Real(context: Context) : Settings.Real(context), PlaybackSettings { + override val inListPlaybackMode: MusicMode + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) + ?: MusicMode.SONGS + + override val inParentPlaybackMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE)) + + override val barAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE)) + ?: ActionMode.NEXT + + override val notificationAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_notif_action), Int.MIN_VALUE)) + ?: ActionMode.REPEAT + + override val headsetAutoplay: Boolean + get() = + sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false) + + override val replayGainMode: ReplayGainMode + get() = + ReplayGainMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) + ?: ReplayGainMode.DYNAMIC + + override var replayGainPreAmp: ReplayGainPreAmp + get() = + ReplayGainPreAmp( + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f), + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f)) + set(value) { + sharedPreferences.edit { + putFloat(getString(R.string.set_key_pre_amp_with), value.with) + putFloat(getString(R.string.set_key_pre_amp_without), value.without) + apply() + } + } + + override val keepShuffle: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true) + + override val rewindWithPrev: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true) + + override val pauseOnRepeat: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) + + override fun migrate() { + // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. + if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) { + logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION") + + val mode = + if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) { + ActionMode.SHUFFLE + } else { + ActionMode.REPEAT + } + + sharedPreferences.edit { + putInt(getString(R.string.set_key_notif_action), mode.intCode) + remove(OLD_KEY_ALT_NOTIF_ACTION) + apply() + } + } + + // PlaybackMode was converted to MusicMode in 3.0.0 + + fun Int.migratePlaybackMode() = + when (this) { + // Convert PlaybackMode into MusicMode + IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS + IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS + IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS + IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES + else -> null + } + + if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE") + + val mode = + sharedPreferences + .getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) + .migratePlaybackMode() + ?: MusicMode.SONGS + + sharedPreferences.edit { + putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode) + remove(OLD_KEY_LIB_PLAYBACK_MODE) + apply() + } + } + + if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE") + + val mode = + sharedPreferences + .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE) + .migratePlaybackMode() + + sharedPreferences.edit { + putInt( + getString(R.string.set_key_in_parent_playback_mode), + mode?.intCode ?: Int.MIN_VALUE) + remove(OLD_KEY_DETAIL_PLAYBACK_MODE) + apply() + } + } + } + + override fun onSettingChanged(key: String, listener: Listener) { + when (key) { + getString(R.string.set_key_replay_gain), + getString(R.string.set_key_pre_amp_with), + getString(R.string.set_key_pre_amp_without) -> + listener.onReplayGainSettingsChanged() + getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged() + } + } + + private companion object { + const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" + const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" + const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): PlaybackSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 2f5ea9f14..097a9fab9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -26,11 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.playback.state.InternalPlayer -import org.oxycblt.auxio.playback.state.PlaybackStateDatabase -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.state.* import org.oxycblt.auxio.util.context /** @@ -39,8 +35,10 @@ import org.oxycblt.auxio.util.context */ class PlaybackViewModel(application: Application) : AndroidViewModel(application), PlaybackStateManager.Listener { - private val settings = Settings(application) + private val musicSettings = MusicSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private val playbackManager = PlaybackStateManager.getInstance() + private val musicStore = MusicStore.getInstance() private var lastPositionJob: Job? = null private val _song = MutableStateFlow(null) @@ -85,6 +83,10 @@ class PlaybackViewModel(application: Application) : val genrePickerSong: StateFlow get() = _genrePlaybackPickerSong + /** The current action to show on the playback bar. */ + val currentBarAction: ActionMode + get() = playbackSettings.barAction + /** * The current audio session ID of the internal player. Null if no [InternalPlayer] is * available. @@ -100,13 +102,25 @@ class PlaybackViewModel(application: Application) : playbackManager.removeListener(this) } - override fun onIndexMoved(index: Int) { - _song.value = playbackManager.song + override fun onIndexMoved(queue: Queue) { + _song.value = queue.currentSong } - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { - _song.value = playbackManager.song - _parent.value = playbackManager.parent + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { + // Other types of queue changes preserve the current song. + if (change == Queue.ChangeResult.SONG) { + _song.value = queue.currentSong + } + } + + override fun onQueueReordered(queue: Queue) { + _isShuffled.value = queue.isShuffled + } + + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + _song.value = queue.currentSong + _parent.value = parent + _isShuffled.value = queue.isShuffled } override fun onStateChanged(state: InternalPlayer.State) { @@ -126,35 +140,33 @@ class PlaybackViewModel(application: Application) : } } - override fun onShuffledChanged(isShuffled: Boolean) { - _isShuffled.value = isShuffled - } - override fun onRepeatChanged(repeatMode: RepeatMode) { _repeatMode.value = repeatMode } // --- PLAYING FUNCTIONS --- - /** - * Play the given [Song] from all songs in the music library. - * @param song The [Song] to play. - */ - fun playFromAll(song: Song) { - playbackManager.play(song, null, settings) - } - /** Shuffle all songs in the music library. */ fun shuffleAll() { - playbackManager.play(null, null, settings, true) + playImpl(null, null, true) } /** - * Play a [Song] from it's [Album]. + * Play a [Song] from the [MusicParent] outlined by the given [MusicMode]. + * - If [MusicMode.SONGS], the [Song] is played from all songs. + * - If [MusicMode.ALBUMS], the [Song] is played from it's [Album]. + * - If [MusicMode.ARTISTS], the [Song] is played from one of it's [Artist]s. + * - If [MusicMode.GENRES], the [Song] is played from one of it's [Genre]s. * @param song The [Song] to play. + * @param playbackMode The [MusicMode] to play from. */ - fun playFromAlbum(song: Song) { - playbackManager.play(song, song.album, settings) + fun playFrom(song: Song, playbackMode: MusicMode) { + when (playbackMode) { + MusicMode.SONGS -> playImpl(song, null) + MusicMode.ALBUMS -> playImpl(song, song.album) + MusicMode.ARTISTS -> playFromArtist(song) + MusicMode.GENRES -> playFromGenre(song) + } } /** @@ -165,10 +177,9 @@ class PlaybackViewModel(application: Application) : */ fun playFromArtist(song: Song, artist: Artist? = null) { if (artist != null) { - check(artist in song.artists) { "Artist not in song artists" } - playbackManager.play(song, artist, settings) + playImpl(song, artist) } else if (song.artists.size == 1) { - playbackManager.play(song, song.artists[0], settings) + playImpl(song, song.artists[0]) } else { _artistPlaybackPickerSong.value = song } @@ -191,61 +202,91 @@ class PlaybackViewModel(application: Application) : */ fun playFromGenre(song: Song, genre: Genre? = null) { if (genre != null) { - check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" } - playbackManager.play(song, genre, settings) + playImpl(song, genre) } else if (song.genres.size == 1) { - playbackManager.play(song, song.genres[0], settings) + playImpl(song, song.genres[0]) } else { _genrePlaybackPickerSong.value = song } } + /** + * Mark the [Genre] playback choice process as complete. This should occur when the [Genre] + * choice dialog is opened after this flag is detected. + * @see playFromGenre + */ + fun finishPlaybackGenrePicker() { + _genrePlaybackPickerSong.value = null + } + /** * Play an [Album]. * @param album The [Album] to play. */ - fun play(album: Album) { - playbackManager.play(null, album, settings, false) - } + fun play(album: Album) = playImpl(null, album, false) /** * Play an [Artist]. * @param artist The [Artist] to play. */ - fun play(artist: Artist) { - playbackManager.play(null, artist, settings, false) - } + fun play(artist: Artist) = playImpl(null, artist, false) /** * Play a [Genre]. * @param genre The [Genre] to play. */ - fun play(genre: Genre) { - playbackManager.play(null, genre, settings, false) - } + fun play(genre: Genre) = playImpl(null, genre, false) + + /** + * Play a [Music] selection. + * @param selection The selection to play. + */ + fun play(selection: List) = + playbackManager.play(null, null, selectionToSongs(selection), false) /** * Shuffle an [Album]. * @param album The [Album] to shuffle. */ - fun shuffle(album: Album) { - playbackManager.play(null, album, settings, true) - } + fun shuffle(album: Album) = playImpl(null, album, true) /** * Shuffle an [Artist]. * @param artist The [Artist] to shuffle. */ - fun shuffle(artist: Artist) { - playbackManager.play(null, artist, settings, true) - } + fun shuffle(artist: Artist) = playImpl(null, artist, true) /** * Shuffle an [Genre]. * @param genre The [Genre] to shuffle. */ - fun shuffle(genre: Genre) { - playbackManager.play(null, genre, settings, true) + fun shuffle(genre: Genre) = playImpl(null, genre, true) + + /** + * Shuffle a [Music] selection. + * @param selection The selection to shuffle. + */ + fun shuffle(selection: List) = + playbackManager.play(null, null, selectionToSongs(selection), true) + + private fun playImpl( + song: Song?, + parent: MusicParent?, + shuffled: Boolean = playbackManager.queue.isShuffled && playbackSettings.keepShuffle + ) { + check(song == null || parent == null || parent.songs.contains(song)) { + "Song to play not in parent" + } + val library = musicStore.library ?: return + val sort = + when (parent) { + is Genre -> musicSettings.genreSongSort + is Artist -> musicSettings.artistSongSort + is Album -> musicSettings.albumSongSort + null -> musicSettings.songSort + } + val queue = sort.songs(parent?.songs ?: library.songs) + playbackManager.play(song, parent, queue, shuffled) } /** @@ -284,8 +325,6 @@ class PlaybackViewModel(application: Application) : * @param song The [Song] to add. */ fun playNext(song: Song) { - // TODO: Queue additions without a playing song should map to playing items - // (impossible until queue rework) playbackManager.playNext(song) } @@ -294,7 +333,7 @@ class PlaybackViewModel(application: Application) : * @param album The [Album] to add. */ fun playNext(album: Album) { - playbackManager.playNext(settings.detailAlbumSort.songs(album.songs)) + playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs)) } /** @@ -302,7 +341,7 @@ class PlaybackViewModel(application: Application) : * @param artist The [Artist] to add. */ fun playNext(artist: Artist) { - playbackManager.playNext(settings.detailArtistSort.songs(artist.songs)) + playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs)) } /** @@ -310,7 +349,7 @@ class PlaybackViewModel(application: Application) : * @param genre The [Genre] to add. */ fun playNext(genre: Genre) { - playbackManager.playNext(settings.detailGenreSort.songs(genre.songs)) + playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs)) } /** @@ -334,7 +373,7 @@ class PlaybackViewModel(application: Application) : * @param album The [Album] to add. */ fun addToQueue(album: Album) { - playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs)) + playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs)) } /** @@ -342,7 +381,7 @@ class PlaybackViewModel(application: Application) : * @param artist The [Artist] to add. */ fun addToQueue(artist: Artist) { - playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs)) + playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs)) } /** @@ -350,7 +389,7 @@ class PlaybackViewModel(application: Application) : * @param genre The [Genre] to add. */ fun addToQueue(genre: Genre) { - playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs)) + playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs)) } /** @@ -364,13 +403,13 @@ class PlaybackViewModel(application: Application) : // --- STATUS FUNCTIONS --- /** Toggle [isPlaying] (i.e from playing to paused) */ - fun toggleIsPlaying() { + fun togglePlaying() { playbackManager.setPlaying(!playbackManager.playerState.isPlaying) } /** Toggle [isShuffled] (ex. from on to off) */ - fun invertShuffled() { - playbackManager.reshuffle(!playbackManager.isShuffled, settings) + fun toggleShuffled() { + playbackManager.reorder(!playbackManager.queue.isShuffled) } /** @@ -427,9 +466,9 @@ class PlaybackViewModel(application: Application) : private fun selectionToSongs(selection: List): List { return selection.flatMap { when (it) { - is Album -> settings.detailAlbumSort.songs(it.songs) - is Artist -> settings.detailArtistSort.songs(it.songs) - is Genre -> settings.detailGenreSort.songs(it.songs) + is Album -> musicSettings.albumSongSort.songs(it.songs) + is Artist -> musicSettings.artistSongSort.songs(it.songs) + is Genre -> musicSettings.genreSongSort.songs(it.songs) is Song -> listOf(it) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index baa4aa76c..1898af497 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -27,31 +27,28 @@ import com.google.android.material.shape.MaterialShapeDrawable import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.ItemQueueSongBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.DiffAdapter +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder -import org.oxycblt.auxio.list.recycler.SyncListDiffer import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.util.context -import org.oxycblt.auxio.util.getAttrColorCompat -import org.oxycblt.auxio.util.getDimen -import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.* /** * A [RecyclerView.Adapter] that shows an editable list of queue items. * @param listener A [EditableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class QueueAdapter(private val listener: EditableListListener) : - RecyclerView.Adapter() { - private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK) +class QueueAdapter(private val listener: EditableListListener) : + DiffAdapter( + ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) { // Since PlayingIndicator adapter relies on an item value, we cannot use it for this // adapter, as one item can appear at several points in the UI. Use a similar implementation // with an index value instead. private var currentIndex = 0 private var isPlaying = false - override fun getItemCount() = differ.currentList.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = QueueSongViewHolder.from(parent) @@ -64,31 +61,13 @@ class QueueAdapter(private val listener: EditableListListener) : payload: List ) { if (payload.isEmpty()) { - viewHolder.bind(differ.currentList[position], listener) + viewHolder.bind(getItem(position), listener) } viewHolder.isFuture = position > currentIndex viewHolder.updatePlayingIndicator(position == currentIndex, isPlaying) } - /** - * Synchronously update the list with new items. This is exceedingly slow for large diffs, so - * only use it for trivial updates. - * @param newList The new [Song]s for the adapter to display. - */ - fun submitList(newList: List) { - differ.submitList(newList) - } - - /** - * Replace the list with a new list. This is exceedingly slow for large diffs, so only use it - * for trivial updates. - * @param newList The new [Song]s for the adapter to display. - */ - fun replaceList(newList: List) { - differ.replaceList(newList) - } - /** * Set the position of the currently playing item in the queue. This will mark the item as * playing and any previous items as played. @@ -96,30 +75,19 @@ class QueueAdapter(private val listener: EditableListListener) : * @param isPlaying Whether playback is ongoing or paused. */ fun setPosition(index: Int, isPlaying: Boolean) { - var updatedIndex = false + logD("Updating index") + val lastIndex = currentIndex + currentIndex = index - if (index != currentIndex) { - val lastIndex = currentIndex - currentIndex = index - updatedIndex = true - - // Have to update not only the currently playing item, but also all items marked - // as playing. - if (currentIndex < lastIndex) { - notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION) - } else { - notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION) - } + // Have to update not only the currently playing item, but also all items marked + // as playing. + if (currentIndex < lastIndex) { + notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION) + } else { + notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION) } - if (this.isPlaying != isPlaying) { - this.isPlaying = isPlaying - // Don't need to do anything if we've already sent an update from changing the - // index. - if (!updatedIndex) { - notifyItemChanged(index, PAYLOAD_UPDATE_POSITION) - } - } + this.isPlaying = isPlaying } private companion object { @@ -158,7 +126,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong binding.songAlbumCover.isEnabled = value binding.songName.isEnabled = value binding.songInfo.isEnabled = value - binding.songDragHandle.isEnabled = value } init { @@ -178,7 +145,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong * @param listener A [EditableListListener] to bind interactions to. */ @SuppressLint("ClickableViewAccessibility") - fun bind(song: Song, listener: EditableListListener) { + fun bind(song: Song, listener: EditableListListener) { listener.bind(song, this, bodyView, binding.songDragHandle) binding.songAlbumCover.bind(song) binding.songName.text = song.resolveName(binding.context) @@ -202,6 +169,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong fun from(parent: View) = QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater)) + // TODO: This is not good enough, I need to compare item indices as well. /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt index 1fb220c9b..c8308ef70 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueDragCallback.kt @@ -30,26 +30,17 @@ import org.oxycblt.auxio.util.logD /** * A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in the queue UI, * such as an animation when lifting items. + * + * TODO: Why is item movement so expensive??? + * * @author Alexander Capehart (OxygenCobalt) */ class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() { private var shouldLift = true - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - val queueHolder = viewHolder as QueueSongViewHolder - return if (queueHolder.isFuture) { - makeFlag( - ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or - makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) - } else { - // Avoid allowing any touch actions for already-played queue items, as the playback - // system does not currently allow for this. - 0 - } - } + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = + makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or + makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START) override fun onChildDraw( c: Canvas, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 826d04270..50c363721 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -27,19 +27,18 @@ import androidx.recyclerview.widget.RecyclerView import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.list.EditableListListener -import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD /** * A [ViewBindingFragment] that displays an editable queue. * @author Alexander Capehart (OxygenCobalt) */ -class QueueFragment : ViewBindingFragment(), EditableListListener { +class QueueFragment : ViewBindingFragment(), EditableListListener { private val queueModel: QueueViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val queueAdapter = QueueAdapter(this) @@ -78,10 +77,11 @@ class QueueFragment : ViewBindingFragment(), EditableListL override fun onDestroyBinding(binding: FragmentQueueBinding) { super.onDestroyBinding(binding) + touchHelper = null binding.queueRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { + override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { queueModel.goto(viewHolder.bindingAdapterPosition) } @@ -100,18 +100,13 @@ class QueueFragment : ViewBindingFragment(), EditableListL val binding = requireBinding() // Replace or diff the queue depending on the type of change it is. - // TODO: Extend this to the whole app. - if (queueModel.replaceQueue == true) { - logD("Replacing queue") - queueAdapter.replaceList(queue) - } else { - logD("Diffing queue") - queueAdapter.submitList(queue) - } - queueModel.finishReplace() + val instructions = queueModel.queueListInstructions + queueAdapter.submitList(queue, instructions?.update ?: BasicListInstructions.DIFF) + // Update position in list (and thus past/future items) + queueAdapter.setPosition(index, isPlaying) // If requested, scroll to a new item (occurs when the index moves) - val scrollTo = queueModel.scrollTo + val scrollTo = instructions?.scrollTo if (scrollTo != null) { val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager val start = lmm.findFirstCompletelyVisibleItemPosition() @@ -126,15 +121,13 @@ class QueueFragment : ViewBindingFragment(), EditableListL binding.queueRecycler.scrollToPosition(scrollTo) } else if (scrollTo > end) { // We need to scroll downwards, we need to offset by a screen of songs. - // This does have some error due to what the layout manager returns being - // somewhat mutable. This is considered okay. + // This does have some error due to how many completely visible items on-screen + // can vary. This is considered okay. binding.queueRecycler.scrollToPosition( min(queue.lastIndex, scrollTo + (end - start))) } } - queueModel.finishScrollTo() - // Update position in list (and thus past/future items) - queueAdapter.setPosition(index, isPlaying) + queueModel.finishInstructions() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index fb30d6c5c..71bbe02e9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -20,9 +20,11 @@ package org.oxycblt.auxio.playback.queue import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. @@ -36,30 +38,58 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { /** The current queue. */ val queue: StateFlow> = _queue - private val _index = MutableStateFlow(playbackManager.index) + private val _index = MutableStateFlow(playbackManager.queue.index) /** The index of the currently playing song in the queue. */ val index: StateFlow get() = _index - /** Whether to replace or diff the queue list when updating it. Is null if not specified. */ - var replaceQueue: Boolean? = null - /** Flag to scroll to a particular queue item. Is null if no command has been specified. */ - var scrollTo: Int? = null + /** Specifies how to update the list when the queue changes. */ + var queueListInstructions: ListInstructions? = null init { playbackManager.addListener(this) } + override fun onIndexMoved(queue: Queue) { + queueListInstructions = ListInstructions(null, queue.index) + _index.value = queue.index + } + + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { + // Queue changed trivially due to item mo -> Diff queue, stay at current index. + queueListInstructions = ListInstructions(BasicListInstructions.DIFF, null) + _queue.value = queue.resolve() + if (change != Queue.ChangeResult.MAPPING) { + // Index changed, make sure it remains updated without actually scrolling to it. + _index.value = queue.index + } + } + + override fun onQueueReordered(queue: Queue) { + // Queue changed completely -> Replace queue, update index + queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index) + _queue.value = queue.resolve() + _index.value = queue.index + } + + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + // Entirely new queue -> Replace queue, update index + queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index) + _queue.value = queue.resolve() + _index.value = queue.index + } + + override fun onCleared() { + super.onCleared() + playbackManager.removeListener(this) + } + /** * Start playing the the queue item at the given index. * @param adapterIndex The index of the queue item to play. Does nothing if the index is out of * range. */ fun goto(adapterIndex: Int) { - if (adapterIndex !in playbackManager.queue.indices) { - // Invalid input. Nothing to do. - return - } playbackManager.goto(adapterIndex) } @@ -69,10 +99,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * range. */ fun removeQueueDataItem(adapterIndex: Int) { - if (adapterIndex <= playbackManager.index || - adapterIndex !in playbackManager.queue.indices) { - // Invalid input. Nothing to do. - // TODO: Allow editing played queue items. + if (adapterIndex !in queue.value.indices) { return } playbackManager.removeQueueItem(adapterIndex) @@ -85,56 +112,17 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { * @return true if the items were moved, false otherwise. */ fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean { - if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) { - // Invalid input. Nothing to do. + if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) { return false } playbackManager.moveQueueItem(adapterFrom, adapterTo) return true } - /** Finish a replace flag specified by [replaceQueue]. */ - fun finishReplace() { - replaceQueue = null + /** Signal that the specified [ListInstructions] in [queueListInstructions] were performed. */ + fun finishInstructions() { + queueListInstructions = null } - /** Finish a scroll operation started by [scrollTo]. */ - fun finishScrollTo() { - scrollTo = null - } - - override fun onIndexMoved(index: Int) { - // Index moved -> Scroll to new index - replaceQueue = null - scrollTo = index - _index.value = index - } - - override fun onQueueChanged(queue: List) { - // Queue changed trivially due to item move -> Diff queue, stay at current index. - replaceQueue = false - scrollTo = null - _queue.value = playbackManager.queue.toMutableList() - } - - override fun onQueueReworked(index: Int, queue: List) { - // Queue changed completely -> Replace queue, update index - replaceQueue = true - scrollTo = index - _queue.value = playbackManager.queue.toMutableList() - _index.value = index - } - - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { - // Entirely new queue -> Replace queue, update index - replaceQueue = true - scrollTo = index - _queue.value = playbackManager.queue.toMutableList() - _index.value = index - } - - override fun onCleared() { - super.onCleared() - playbackManager.removeListener(this) - } + class ListInstructions(val update: BasicListInstructions?, val scrollTo: Int?) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index 5cada49e2..fa589f015 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -24,7 +24,7 @@ import androidx.appcompat.app.AlertDialog import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment /** @@ -39,11 +39,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_pre_amp) .setPositiveButton(R.string.lbl_ok) { _, _ -> val binding = requireBinding() - Settings(requireContext()).replayGainPreAmp = + PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) } .setNeutralButton(R.string.lbl_reset) { _, _ -> - Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) + PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) } .setNegativeButton(R.string.lbl_cancel, null) } @@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { // First initialization, we need to supply the sliders with the values from // settings. After this, the sliders save their own state, so we do not need to // do any restore behavior. - val preAmp = Settings(requireContext()).replayGainPreAmp + val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp binding.withTagsSlider.value = preAmp.with binding.withoutTagsSlider.value = preAmp.without } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 98450a763..3a2a39893 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.playback.replaygain import android.content.Context -import android.content.SharedPreferences import com.google.android.exoplayer2.C import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.Player @@ -28,11 +27,10 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor import com.google.android.exoplayer2.util.MimeTypes import java.nio.ByteBuffer import kotlin.math.pow -import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.extractor.Tags +import org.oxycblt.auxio.music.extractor.TextTags +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD /** @@ -45,10 +43,10 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor(private val context: Context) : - BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener { +class ReplayGainAudioProcessor(context: Context) : + BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val playbackSettings = PlaybackSettings.from(context) private var lastFormat: Format? = null private var volume = 1f @@ -65,7 +63,7 @@ class ReplayGainAudioProcessor(private val context: Context) : */ fun addToListeners(player: Player) { player.addListener(this) - settings.addListener(this) + playbackSettings.registerListener(this) } /** @@ -75,7 +73,7 @@ class ReplayGainAudioProcessor(private val context: Context) : */ fun releaseFromListeners(player: Player) { player.removeListener(this) - settings.removeListener(this) + playbackSettings.unregisterListener(this) } // --- OVERRIDES --- @@ -98,13 +96,9 @@ class ReplayGainAudioProcessor(private val context: Context) : applyReplayGain(null) } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == context.getString(R.string.set_key_replay_gain) || - key == context.getString(R.string.set_key_pre_amp_with) || - key == context.getString(R.string.set_key_pre_amp_without)) { - // ReplayGain changed, we need to set it up again. - applyReplayGain(lastFormat) - } + override fun onReplayGainSettingsChanged() { + // ReplayGain config changed, we need to set it up again. + applyReplayGain(lastFormat) } // --- REPLAYGAIN PARSING --- @@ -116,26 +110,24 @@ class ReplayGainAudioProcessor(private val context: Context) : private fun applyReplayGain(format: Format?) { lastFormat = format val gain = parseReplayGain(format ?: return) - val preAmp = settings.replayGainPreAmp + val preAmp = playbackSettings.replayGainPreAmp val adjust = if (gain != null) { logD("Found ReplayGain adjustment $gain") // ReplayGain is configurable, so determine what to do based off of the mode. val useAlbumGain = - when (settings.replayGainMode) { + when (playbackSettings.replayGainMode) { // User wants track gain to be preferred. Default to album gain only if // there is no track gain. ReplayGainMode.TRACK -> gain.track == 0f - // User wants album gain to be preferred. Default to track gain only if // here is no album gain. ReplayGainMode.ALBUM -> gain.album != 0f - // User wants album gain to be used when in an album, track gain otherwise. ReplayGainMode.DYNAMIC -> playbackManager.parent is Album && - playbackManager.song?.album == playbackManager.parent + playbackManager.queue.currentSong?.album == playbackManager.parent } val resolvedGain = @@ -168,35 +160,35 @@ class ReplayGainAudioProcessor(private val context: Context) : * @return A [Adjustment] adjustment, or null if there were no valid adjustments. */ private fun parseReplayGain(format: Format): Adjustment? { - val tags = Tags(format.metadata ?: return null) + val textTags = TextTags(format.metadata ?: return null) var trackGain = 0f var albumGain = 0f // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom // replaygain_*_gain tag. if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) { - tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] + textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it } - tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] + textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it } - tags.vorbis[TAG_RG_ALBUM_GAIN] + textTags.vorbis[TAG_RG_ALBUM_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it } - tags.vorbis[TAG_RG_TRACK_GAIN] + textTags.vorbis[TAG_RG_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it } } else { // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the // adjustment by 256 to get the gain. This is used alongside the base adjustment // intrinsic to the format to create the normalized adjustment. That base adjustment - // is already handled by the media framework, so we just need to apply the more + // is already handled by the media framework, so we just need to apply the more // specific adjustments. - tags.vorbis[TAG_R128_TRACK_GAIN] + textTags.vorbis[TAG_R128_TRACK_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { trackGain = it / 256f } - tags.vorbis[TAG_R128_ALBUM_GAIN] + textTags.vorbis[TAG_R128_ALBUM_GAIN] ?.run { first().parseReplayGainAdjustment() } ?.let { albumGain = it / 256f } } @@ -231,27 +223,32 @@ class ReplayGainAudioProcessor(private val context: Context) : throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) } - override fun isActive() = super.isActive() && volume != 1f - override fun queueInput(inputBuffer: ByteBuffer) { - val position = inputBuffer.position() + val pos = inputBuffer.position() val limit = inputBuffer.limit() - val size = limit - position - val buffer = replaceOutputBuffer(size) + val buffer = replaceOutputBuffer(limit - pos) - for (i in position until limit step 2) { - // Ensure we clamp the values to the minimum and maximum values possible - // for the encoding. This prevents issues where samples amplified beyond - // 1 << 16 will end up becoming truncated during the conversion to a short, - // resulting in popping. - var sample = inputBuffer.getLeShort(i) - sample = - (sample * volume) - .toInt() - .coerceAtLeast(Short.MIN_VALUE.toInt()) - .coerceAtMost(Short.MAX_VALUE.toInt()) - .toShort() - buffer.putLeShort(sample) + if (volume == 1f) { + // Nothing to adjust, just copy the audio data. + // isActive is technically a much better way of doing a no-op like this, but since + // the adjustment can change during playback I'm largely forced to do this. + buffer.put(inputBuffer.slice()) + } else { + for (i in pos until limit step 2) { + // 16-bit PCM audio, deserialize a little-endian short. + var sample = inputBuffer.getLeShort(i) + // Ensure we clamp the values to the minimum and maximum values possible + // for the encoding. This prevents issues where samples amplified beyond + // 1 << 16 will end up becoming truncated during the conversion to a short, + // resulting in popping. + sample = + (sample * volume) + .toInt() + .coerceAtLeast(Short.MIN_VALUE.toInt()) + .coerceAtMost(Short.MAX_VALUE.toInt()) + .toShort() + buffer.putLeShort(sample) + } } inputBuffer.position(limit) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt index f63e5baf4..717750dbf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt @@ -22,11 +22,10 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns +import androidx.core.database.getIntOrNull import androidx.core.database.sqlite.transaction -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.* /** @@ -42,17 +41,22 @@ class PlaybackStateDatabase private constructor(context: Context) : // of the non-queue parts of the state, such as the playback position. db.createTable(TABLE_STATE) { append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${StateColumns.INDEX} INTEGER NOT NULL,") - append("${StateColumns.POSITION} LONG NOT NULL,") - append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,") - append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,") - append("${StateColumns.SONG_UID} STRING,") - append("${StateColumns.PARENT_UID} STRING") + append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,") + append("${PlaybackStateColumns.POSITION} LONG NOT NULL,") + append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,") + append("${PlaybackStateColumns.SONG_UID} STRING,") + append("${PlaybackStateColumns.PARENT_UID} STRING") } - db.createTable(TABLE_QUEUE) { + db.createTable(TABLE_QUEUE_HEAP) { append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${QueueColumns.SONG_UID} STRING NOT NULL") + append("${QueueHeapColumns.SONG_UID} STRING NOT NULL") + } + + db.createTable(TABLE_QUEUE_MAPPINGS) { + append("${BaseColumns._ID} INTEGER PRIMARY KEY,") + append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,") + append("${QueueMappingColumns.SHUFFLED_INDEX} INT") } } @@ -63,7 +67,8 @@ class PlaybackStateDatabase private constructor(context: Context) : logD("Nuking database") db.apply { execSQL("DROP TABLE IF EXISTS $TABLE_STATE") - execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE") + execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP") + execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS") onCreate(this) } } @@ -72,70 +77,85 @@ class PlaybackStateDatabase private constructor(context: Context) : /** * Read a persisted [SavedState] from the database. - * @param library [MusicStore.Library] required to restore [SavedState]. + * @param library [Library] required to restore [SavedState]. * @return A persisted [SavedState], or null if one could not be found. */ - fun read(library: MusicStore.Library): SavedState? { + fun read(library: Library): SavedState? { requireBackgroundThread() // Read the saved state and queue. If the state is non-null, that must imply an // existent, albeit possibly empty, queue. - val rawState = readRawState() ?: return null - val queue = readQueue(library) - // Correct the index to match up with a queue that has possibly been shortened due to - // song removals. - var actualIndex = rawState.index - while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) { - actualIndex-- - } + val rawState = readRawPlaybackState() ?: return null + val rawQueueState = readRawQueueState(library) // Restore parent item from the music library. If this fails, then the playback mode // reverts to "All Songs", which is considered okay. val parent = rawState.parentUid?.let { library.find(it) } return SavedState( - index = actualIndex, parent = parent, - queue = queue, + queueState = + Queue.SavedState( + heap = rawQueueState.heap, + orderedMapping = rawQueueState.orderedMapping, + shuffledMapping = rawQueueState.shuffledMapping, + index = rawState.index, + songUid = rawState.songUid), positionMs = rawState.positionMs, - repeatMode = rawState.repeatMode, - isShuffled = rawState.isShuffled) + repeatMode = rawState.repeatMode) } - private fun readRawState() = + private fun readRawPlaybackState() = readableDatabase.queryAll(TABLE_STATE) { cursor -> if (!cursor.moveToFirst()) { // Empty, nothing to do. return@queryAll null } - val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX) - val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION) - val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE) - val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED) - val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID) - val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID) - RawState( + val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX) + val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION) + val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE) + val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID) + val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID) + RawPlaybackState( index = cursor.getInt(indexIndex), positionMs = cursor.getLong(posIndex), repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) ?: RepeatMode.NONE, - isShuffled = cursor.getInt(shuffleIndex) == 1, songUid = Music.UID.fromString(cursor.getString(songUidIndex)) ?: return@queryAll null, parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) } - private fun readQueue(library: MusicStore.Library): List { - val queue = mutableListOf() - readableDatabase.queryAll(TABLE_QUEUE) { cursor -> - val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID) + private fun readRawQueueState(library: Library): RawQueueState { + val heap = mutableListOf() + readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor -> + if (cursor.count == 0) { + // Empty, nothing to do. + return@queryAll + } + + val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID) while (cursor.moveToNext()) { - val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue - val song = library.find(uid) ?: continue - queue.add(song) + heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find)) + } + } + logD("Successfully read queue of ${heap.size} songs") + + val orderedMapping = mutableListOf() + val shuffledMapping = mutableListOf() + readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor -> + if (cursor.count == 0) { + // Empty, nothing to do. + return@queryAll + } + + val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX) + val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX) + while (cursor.moveToNext()) { + orderedMapping.add(cursor.getInt(orderedIndex)) + cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add) } } - logD("Successfully read queue of ${queue.size} songs") - return queue + return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull()) } /** @@ -145,41 +165,44 @@ class PlaybackStateDatabase private constructor(context: Context) : fun write(state: SavedState?) { requireBackgroundThread() // Only bother saving a state if a song is actively playing from one. - // This is not the case with a null state or a state with an out-of-bounds index. - if (state != null && state.index in state.queue.indices) { + // This is not the case with a null state. + if (state != null) { // Transform saved state into raw state, which can then be written to the database. - val rawState = - RawState( - index = state.index, + val rawPlaybackState = + RawPlaybackState( + index = state.queueState.index, positionMs = state.positionMs, repeatMode = state.repeatMode, - isShuffled = state.isShuffled, - songUid = state.queue[state.index].uid, + songUid = state.queueState.songUid, parentUid = state.parent?.uid) - writeRawState(rawState) - writeQueue(state.queue) + writeRawPlaybackState(rawPlaybackState) + val rawQueueState = + RawQueueState( + heap = state.queueState.heap, + orderedMapping = state.queueState.orderedMapping, + shuffledMapping = state.queueState.shuffledMapping) + writeRawQueueState(rawQueueState) logD("Wrote state") } else { - writeRawState(null) - writeQueue(null) + writeRawPlaybackState(null) + writeRawQueueState(null) logD("Cleared state") } } - private fun writeRawState(rawState: RawState?) { + private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) { writableDatabase.transaction { delete(TABLE_STATE, null, null) - if (rawState != null) { + if (rawPlaybackState != null) { val stateData = ContentValues(7).apply { put(BaseColumns._ID, 0) - put(StateColumns.SONG_UID, rawState.songUid.toString()) - put(StateColumns.POSITION, rawState.positionMs) - put(StateColumns.PARENT_UID, rawState.parentUid?.toString()) - put(StateColumns.INDEX, rawState.index) - put(StateColumns.IS_SHUFFLED, rawState.isShuffled) - put(StateColumns.REPEAT_MODE, rawState.repeatMode.intCode) + put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString()) + put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs) + put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString()) + put(PlaybackStateColumns.INDEX, rawPlaybackState.index) + put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode) } insert(TABLE_STATE, null, stateData) @@ -187,47 +210,54 @@ class PlaybackStateDatabase private constructor(context: Context) : } } - private fun writeQueue(queue: List?) { - writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song -> + private fun writeRawQueueState(rawQueueState: RawQueueState?) { + writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song -> ContentValues(2).apply { put(BaseColumns._ID, i) - put(QueueColumns.SONG_UID, song.uid.toString()) + put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString()) + } + } + + val combinedMapping = + rawQueueState?.run { + if (shuffledMapping.isNotEmpty()) { + orderedMapping.zip(shuffledMapping) + } else { + orderedMapping.map { Pair(it, null) } + } + } + + writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair -> + ContentValues(3).apply { + put(BaseColumns._ID, i) + put(QueueMappingColumns.ORDERED_INDEX, pair.first) + put(QueueMappingColumns.SHUFFLED_INDEX, pair.second) } } } /** * A condensed representation of the playback state that can be persisted. - * @param index The position of the currently playing item in the queue. Can be -1 if the - * persisted index no longer exists. - * @param queue The [Song] queue. - * @param parent The [MusicParent] item currently being played from + * @param parent The [MusicParent] item currently being played from. + * @param queueState The [Queue.SavedState] * @param positionMs The current position in the currently played song, in ms * @param repeatMode The current [RepeatMode]. - * @param isShuffled Whether the queue is shuffled or not. */ data class SavedState( - val index: Int, - val queue: List, val parent: MusicParent?, + val queueState: Queue.SavedState, val positionMs: Long, val repeatMode: RepeatMode, - val isShuffled: Boolean ) - /** - * A lower-level form of [SavedState] that contains additional information to create a more - * reliable restoration process. - */ - private data class RawState( - /** @see SavedState.index */ + /** A lower-level form of [SavedState] that contains individual field-based information. */ + private data class RawPlaybackState( + /** @see Queue.SavedState.index */ val index: Int, /** @see SavedState.positionMs */ val positionMs: Long, /** @see SavedState.repeatMode */ val repeatMode: RepeatMode, - /** @see SavedState.isShuffled */ - val isShuffled: Boolean, /** * The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be * used to restore the currently playing item in the queue if the index mapping changed. @@ -237,33 +267,50 @@ class PlaybackStateDatabase private constructor(context: Context) : val parentUid: Music.UID? ) + /** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */ + private data class RawQueueState( + /** @see Queue.SavedState.heap */ + val heap: List, + /** @see Queue.SavedState.orderedMapping */ + val orderedMapping: List, + /** @see Queue.SavedState.shuffledMapping */ + val shuffledMapping: List + ) + /** Defines the columns used in the playback state table. */ - private object StateColumns { - /** @see RawState.index */ + private object PlaybackStateColumns { + /** @see RawPlaybackState.index */ const val INDEX = "queue_index" - /** @see RawState.positionMs */ + /** @see RawPlaybackState.positionMs */ const val POSITION = "position" - /** @see RawState.isShuffled */ - const val IS_SHUFFLED = "is_shuffling" - /** @see RawState.repeatMode */ + /** @see RawPlaybackState.repeatMode */ const val REPEAT_MODE = "repeat_mode" - /** @see RawState.songUid */ + /** @see RawPlaybackState.songUid */ const val SONG_UID = "song_uid" - /** @see RawState.parentUid */ + /** @see RawPlaybackState.parentUid */ const val PARENT_UID = "parent" } - /** Defines the columns used in the queue table. */ - private object QueueColumns { + /** Defines the columns used in the queue heap table. */ + private object QueueHeapColumns { /** @see Music.UID */ const val SONG_UID = "song_uid" } + /** Defines the columns used in the queue mapping table. */ + private object QueueMappingColumns { + /** @see Queue.SavedState.orderedMapping */ + const val ORDERED_INDEX = "ordered_index" + /** @see Queue.SavedState.shuffledMapping */ + const val SHUFFLED_INDEX = "shuffled_index" + } + companion object { private const val DB_NAME = "auxio_playback_state.db" - private const val DB_VERSION = 8 + private const val DB_VERSION = 9 private const val TABLE_STATE = "playback_state" - private const val TABLE_QUEUE = "queue" + private const val TABLE_QUEUE_HEAP = "queue_heap" + private const val TABLE_QUEUE_MAPPINGS = "queue_mapping" @Volatile private var INSTANCE: PlaybackStateDatabase? = null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index fb2995839..734966f89 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -17,21 +17,17 @@ package org.oxycblt.auxio.playback.state -import kotlin.math.max import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Core playback state controller class. @@ -59,22 +55,13 @@ class PlaybackStateManager private constructor() { @Volatile private var pendingAction: InternalPlayer.Action? = null @Volatile private var isInitialized = false - /** The currently playing [Song]. Null if nothing is playing. */ - val song - get() = queue.getOrNull(index) + /** The current [Queue]. */ + val queue = Queue() /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ @Volatile - var parent: MusicParent? = null + var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing. private set - @Volatile private var _queue = mutableListOf() - /** The current queue. */ - val queue - get() = _queue - /** The position of the currently playing item in the queue. */ - @Volatile - var index = -1 - private set /** The current [InternalPlayer] state. */ @Volatile var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) @@ -86,13 +73,8 @@ class PlaybackStateManager private constructor() { field = value notifyRepeatModeChanged() } - /** Whether the queue is shuffled. */ - @Volatile - var isShuffled = false - private set /** - * The current audio session ID of the internal player. Null if no [InternalPlayer] is - * available. + * The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable. */ val currentAudioSessionId: Int? get() = internalPlayer?.audioSessionId @@ -106,9 +88,8 @@ class PlaybackStateManager private constructor() { @Synchronized fun addListener(listener: Listener) { if (isInitialized) { - listener.onNewPlayback(index, queue, parent) + listener.onNewPlayback(queue, parent) listener.onRepeatChanged(repeatMode) - listener.onShuffledChanged(isShuffled) listener.onStateChanged(playerState) } @@ -116,7 +97,7 @@ class PlaybackStateManager private constructor() { } /** - * Remove a [Listener] from this instance, preventing it from recieving any further updates. + * Remove a [Listener] from this instance, preventing it from receiving any further updates. * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in * the first place. * @see Listener @@ -135,13 +116,13 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun registerInternalPlayer(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer != null) { + if (this.internalPlayer != null) { logW("Internal player is already registered") return } if (isInitialized) { - internalPlayer.loadSong(song, playerState.isPlaying) + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) internalPlayer.seekTo(playerState.calculateElapsedPositionMs()) // See if there's any action that has been queued. requestAction(internalPlayer) @@ -160,7 +141,7 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + if (this.internalPlayer !== internalPlayer) { logW("Given internal player did not match current internal player") return } @@ -173,29 +154,20 @@ class PlaybackStateManager private constructor() { /** * Start new playback. * @param song A particular [Song] to play, or null to play the first [Song] in the new queue. - * @param parent The [MusicParent] to play from, or null if to play from the entire - * [MusicStore.Library]. - * @param settings [Settings] required to configure the queue. - * @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle" - * configuration. + * @param queue The queue of [Song]s to play from. + * @param parent The [MusicParent] to play from, or null if to play from an non-specific + * collection of "All [Song]s". + * @param shuffled Whether to shuffle or not. */ @Synchronized - fun play( - song: Song?, - parent: MusicParent?, - settings: Settings, - shuffled: Boolean = settings.keepShuffle && isShuffled - ) { + fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { val internalPlayer = internalPlayer ?: return - val library = musicStore.library ?: return - // Setup parent and queue + // Set up parent and queue this.parent = parent - _queue = (parent?.songs ?: library.songs).toMutableList() - orderQueue(settings, shuffled, song) + this.queue.start(song, queue, shuffled) // Notify components of changes notifyNewPlayback() - notifyShuffledChanged() - internalPlayer.loadSong(this.song, true) + internalPlayer.loadSong(this.queue.currentSong, true) // Played something, so we are initialized now isInitialized = true } @@ -209,13 +181,13 @@ class PlaybackStateManager private constructor() { @Synchronized fun next() { val internalPlayer = internalPlayer ?: return - // Increment the index, if it cannot be incremented any further, then - // repeat and pause/resume playback depending on the setting - if (index < _queue.lastIndex) { - gotoImpl(internalPlayer, index + 1, true) - } else { - gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL) + var play = true + if (!queue.goto(queue.index + 1)) { + queue.goto(0) + play = false } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, play) } /** @@ -231,7 +203,11 @@ class PlaybackStateManager private constructor() { rewind() setPlaying(true) } else { - gotoImpl(internalPlayer, max(index - 1, 0), true) + if (!queue.goto(queue.index - 1)) { + queue.goto(0) + } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) } } @@ -242,24 +218,17 @@ class PlaybackStateManager private constructor() { @Synchronized fun goto(index: Int) { val internalPlayer = internalPlayer ?: return - gotoImpl(internalPlayer, index, true) - } - - private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) { - index = idx - notifyIndexMoved() - internalPlayer.loadSong(song, play) + if (queue.goto(index)) { + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } } /** * Add a [Song] to the top of the queue. * @param song The [Song] to add. */ - @Synchronized - fun playNext(song: Song) { - _queue.add(index + 1, song) - notifyQueueChanged() - } + @Synchronized fun playNext(song: Song) = playNext(listOf(song)) /** * Add [Song]s to the top of the queue. @@ -267,19 +236,24 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun playNext(songs: List) { - _queue.addAll(index + 1, songs) - notifyQueueChanged() + val internalPlayer = internalPlayer ?: return + when (queue.playNext(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } } /** * Add a [Song] to the end of the queue. * @param song The [Song] to add. */ - @Synchronized - fun addToQueue(song: Song) { - _queue.add(song) - notifyQueueChanged() - } + @Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song)) /** * Add [Song]s to the end of the queue. @@ -287,82 +261,53 @@ class PlaybackStateManager private constructor() { */ @Synchronized fun addToQueue(songs: List) { - _queue.addAll(songs) - notifyQueueChanged() + val internalPlayer = internalPlayer ?: return + when (queue.addToQueue(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } } /** * Move a [Song] in the queue. - * @param from The position of the [Song] to move in the queue. - * @param to The destination position in the queue. + * @param src The position of the [Song] to move in the queue. + * @param dst The destination position in the queue. */ @Synchronized - fun moveQueueItem(from: Int, to: Int) { - logD("Moving item $from to position $to") - _queue.add(to, _queue.removeAt(from)) - notifyQueueChanged() + fun moveQueueItem(src: Int, dst: Int) { + logD("Moving item $src to position $dst") + notifyQueueChanged(queue.move(src, dst)) } /** * Remove a [Song] from the queue. - * @param index The position of the [Song] to remove in the queue. + * @param at The position of the [Song] to remove in the queue. */ @Synchronized - fun removeQueueItem(index: Int) { - logD("Removing item ${_queue[index].rawName}") - _queue.removeAt(index) - notifyQueueChanged() + fun removeQueueItem(at: Int) { + val internalPlayer = internalPlayer ?: return + logD("Removing item at $at") + val change = queue.remove(at) + if (change == Queue.ChangeResult.SONG) { + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) + } + notifyQueueChanged(change) } /** * (Re)shuffle or (Re)order this instance. * @param shuffled Whether to shuffle the queue or not. - * @param settings [Settings] required to configure the queue. */ @Synchronized - fun reshuffle(shuffled: Boolean, settings: Settings) { - val song = song ?: return - orderQueue(settings, shuffled, song) - notifyQueueReworked() - notifyShuffledChanged() - } - - /** - * Re-configure the queue. - * @param settings [Settings] required to configure the queue. - * @param shuffled Whether to shuffle the queue or not. - * @param keep the [Song] to start at in the new queue, or null if not specified. - */ - private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) { - val newIndex: Int - if (shuffled) { - // Shuffling queue, randomize the current song list and move the Song to play - // to the start. - _queue.shuffle() - if (keep != null) { - _queue.add(0, _queue.removeAt(_queue.indexOf(keep))) - } - newIndex = 0 - } else { - // Ordering queue, re-sort it using the analogous parent sort configuration and - // then jump to the Song to play. - // TODO: Rework queue system to avoid having to do this - val sort = - parent.let { parent -> - when (parent) { - null -> settings.libSongSort - is Album -> settings.detailAlbumSort - is Artist -> settings.detailArtistSort - is Genre -> settings.detailGenreSort - } - } - sort.songsInPlace(_queue) - newIndex = keep?.let(_queue::indexOf) ?: 0 - } - - _queue = queue - index = newIndex - isShuffled = shuffled + fun reorder(shuffled: Boolean) { + queue.reorder(shuffled) + notifyQueueReordered() } // --- INTERNAL PLAYER FUNCTIONS --- @@ -379,7 +324,7 @@ class PlaybackStateManager private constructor() { return } - val newState = internalPlayer.getState(song?.durationMs ?: 0) + val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0) if (newState != playerState) { playerState = newState notifyStateChanged() @@ -443,7 +388,7 @@ class PlaybackStateManager private constructor() { /** * Restore the previously saved state (if any) and apply it to the playback state. * @param database The [PlaybackStateDatabase] to load from. - * @param force Whether to force a restore regardless of the current state. + * @param force Whether to do a restore regardless of any prior playback state. * @return If the state was restored, false otherwise. */ suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean { @@ -469,22 +414,15 @@ class PlaybackStateManager private constructor() { // State could have changed while we were loading, so check if we were initialized // now before applying the state. if (state != null && (!isInitialized || force)) { - index = state.index parent = state.parent - _queue = state.queue.toMutableList() + queue.applySavedState(state.queueState) repeatMode = state.repeatMode - isShuffled = state.isShuffled - notifyNewPlayback() notifyRepeatModeChanged() - notifyShuffledChanged() - // Continuing playback after drastic state updates is a bad idea, so pause. - internalPlayer.loadSong(song, false) + internalPlayer.loadSong(queue.currentSong, false) internalPlayer.seekTo(state.positionMs) - isInitialized = true - true } else { false @@ -499,17 +437,16 @@ class PlaybackStateManager private constructor() { */ suspend fun saveState(database: PlaybackStateDatabase): Boolean { logD("Saving state to DB") - // Create the saved state from the current playback state. val state = synchronized(this) { - PlaybackStateDatabase.SavedState( - index = index, - parent = parent, - queue = _queue, - positionMs = playerState.calculateElapsedPositionMs(), - isShuffled = isShuffled, - repeatMode = repeatMode) + queue.toSavedState()?.let { + PlaybackStateDatabase.SavedState( + parent = parent, + queueState = it, + positionMs = playerState.calculateElapsedPositionMs(), + repeatMode = repeatMode) + } } return try { withContext(Dispatchers.IO) { database.write(state) } @@ -538,11 +475,11 @@ class PlaybackStateManager private constructor() { } /** - * Update the playback state to align with a new [MusicStore.Library]. - * @param newLibrary The new [MusicStore.Library] that was recently loaded. + * Update the playback state to align with a new [Library]. + * @param newLibrary The new [Library] that was recently loaded. */ @Synchronized - fun sanitize(newLibrary: MusicStore.Library) { + fun sanitize(newLibrary: Library) { if (!isInitialized) { // Nothing playing, nothing to do. logD("Not initialized, no need to sanitize") @@ -566,12 +503,9 @@ class PlaybackStateManager private constructor() { } } - // Sanitize queue. Make sure we re-align the index to point to the previously playing - // Song in the queue queue. - val oldSongUid = song?.uid - _queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) } - while (song?.uid != oldSongUid && index > -1) { - index-- + // Sanitize the queue. + queue.toSavedState()?.let { state -> + queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) } notifyNewPlayback() @@ -579,8 +513,8 @@ class PlaybackStateManager private constructor() { val oldPosition = playerState.calculateElapsedPositionMs() // Continuing playback while also possibly doing drastic state updates is // a bad idea, so pause. - internalPlayer.loadSong(song, false) - if (index > -1) { + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { // Internal player may have reloaded the media item, re-seek to the previous position seekTo(oldPosition) } @@ -590,25 +524,25 @@ class PlaybackStateManager private constructor() { private fun notifyIndexMoved() { for (callback in listeners) { - callback.onIndexMoved(index) + callback.onIndexMoved(queue) } } - private fun notifyQueueChanged() { + private fun notifyQueueChanged(change: Queue.ChangeResult) { for (callback in listeners) { - callback.onQueueChanged(queue) + callback.onQueueChanged(queue, change) } } - private fun notifyQueueReworked() { + private fun notifyQueueReordered() { for (callback in listeners) { - callback.onQueueReworked(index, queue) + callback.onQueueReordered(queue) } } private fun notifyNewPlayback() { for (callback in listeners) { - callback.onNewPlayback(index, queue, parent) + callback.onNewPlayback(queue, parent) } } @@ -624,12 +558,6 @@ class PlaybackStateManager private constructor() { } } - private fun notifyShuffledChanged() { - for (callback in listeners) { - callback.onShuffledChanged(isShuffled) - } - } - /** * The interface for receiving updates from [PlaybackStateManager]. Add the listener to * [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener]. @@ -638,30 +566,30 @@ class PlaybackStateManager private constructor() { /** * Called when the position of the currently playing item has changed, changing the current * [Song], but no other queue attribute has changed. - * @param index The new position in the queue. + * @param queue The new [Queue]. */ - fun onIndexMoved(index: Int) {} + fun onIndexMoved(queue: Queue) {} /** - * Called when the queue changed in a trivial manner, such as a move. - * @param queue The new queue. + * Called when the [Queue] changed in a manner outlined by the given [Queue.ChangeResult]. + * @param queue The new [Queue]. + * @param change The type of [Queue.ChangeResult] that occurred. */ - fun onQueueChanged(queue: List) {} + fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {} /** - * Called when the queue has changed in a non-trivial manner (such as re-shuffling), but the - * currently playing [Song] has not. - * @param index The new position in the queue. + * Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but + * the currently playing [Song] has not. + * @param queue The new [Queue]. */ - fun onQueueReworked(index: Int, queue: List) {} + fun onQueueReordered(queue: Queue) {} /** * Called when a new playback configuration was created. - * @param index The new position in the queue. - * @param queue The new queue. + * @param queue The new [Queue]. * @param parent The new [MusicParent] being played from, or null if playing from all songs. */ - fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) {} + fun onNewPlayback(queue: Queue, parent: MusicParent?) {} /** * Called when the state of the [InternalPlayer] changes. @@ -674,13 +602,6 @@ class PlaybackStateManager private constructor() { * @param repeatMode The new [RepeatMode]. */ fun onRepeatChanged(repeatMode: RepeatMode) {} - - /** - * Called when the queue's shuffle state changes. Handling the queue change itself should - * occur in [onQueueReworked], - * @param isShuffled Whether the queue is shuffled. - */ - fun onShuffledChanged(isShuffled: Boolean) {} } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt new file mode 100644 index 000000000..6638901fd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.state + +import kotlin.random.Random +import kotlin.random.nextInt +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.Song + +/** + * A heap-backed play queue. + * + * Whereas other queue implementations use a plain list, Auxio requires a more complicated data + * structure in order to implement features such as gapless playback in ExoPlayer. This queue + * implementation is instead based around an unorganized "heap" of [Song] instances, that are then + * interpreted into different queues depending on the current playback configuration. + * + * In general, the implementation details don't need to be known for this data structure to be used, + * except in special circumstances like [SavedState]. The functions exposed should be familiar for + * any typical play queue. + * + * @author OxygenCobalt + */ +class Queue { + @Volatile private var heap = mutableListOf() + @Volatile private var orderedMapping = mutableListOf() + @Volatile private var shuffledMapping = mutableListOf() + /** The index of the currently playing [Song] in the current mapping. */ + @Volatile + var index = -1 + private set + /** The currently playing [Song]. */ + val currentSong: Song? + get() = + shuffledMapping + .ifEmpty { orderedMapping.ifEmpty { null } } + ?.getOrNull(index) + ?.let(heap::get) + /** Whether this queue is shuffled. */ + val isShuffled: Boolean + get() = shuffledMapping.isNotEmpty() + + /** + * Resolve this queue into a more conventional list of [Song]s. + * @return A list of [Song] corresponding to the current queue mapping. + */ + fun resolve() = + if (currentSong != null) { + shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } + } else { + // Queue doesn't exist, return saner data. + listOf() + } + + /** + * Go to a particular index in the queue. + * @param to The index of the [Song] to start playing, in the current queue mapping. + * @return true if the queue jumped to that position, false otherwise. + */ + fun goto(to: Int): Boolean { + if (to !in orderedMapping.indices) { + return false + } + index = to + return true + } + + /** + * Start a new queue configuration. + * @param play The [Song] to play, or null to start from a random position. + * @param queue The queue of [Song]s to play. Must contain [play]. This list will become the + * heap internally. + * @param shuffled Whether to shuffle the queue or not. This changes the interpretation of + * [queue]. + */ + fun start(play: Song?, queue: List, shuffled: Boolean) { + heap = queue.toMutableList() + orderedMapping = MutableList(queue.size) { it } + shuffledMapping = mutableListOf() + index = + play?.let(queue::indexOf) ?: if (shuffled) Random.Default.nextInt(queue.indices) else 0 + reorder(shuffled) + check() + } + + /** + * Re-order the queue. + * @param shuffled Whether the queue should be shuffled or not. + */ + fun reorder(shuffled: Boolean) { + if (orderedMapping.isEmpty()) { + // Nothing to do. + return + } + + if (shuffled) { + val trueIndex = + if (shuffledMapping.isNotEmpty()) { + // Re-shuffling, song to preserve is in the shuffled mapping + shuffledMapping[index] + } else { + // First shuffle, song to preserve is in the ordered mapping + orderedMapping[index] + } + + // Since we are re-shuffling existing songs, we use the previous mapping size + // instead of the total queue size. + shuffledMapping = orderedMapping.shuffled().toMutableList() + shuffledMapping.add(0, shuffledMapping.removeAt(shuffledMapping.indexOf(trueIndex))) + index = 0 + } else if (shuffledMapping.isNotEmpty()) { + // Un-shuffling, song to preserve is in the shuffled mapping. + index = orderedMapping.indexOf(shuffledMapping[index]) + shuffledMapping = mutableListOf() + } + check() + } + + /** + * Add [Song]s to the top of the queue. Will start playback if nothing is playing. + * @param songs The [Song]s to add. + * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there + * was no prior playback and these enqueued [Song]s start new playback. + */ + fun playNext(songs: List): ChangeResult { + if (orderedMapping.isEmpty()) { + // No playback, start playing these songs. + start(songs[0], songs, false) + return ChangeResult.SONG + } + + val heapIndices = songs.map(::addSongToHeap) + if (shuffledMapping.isNotEmpty()) { + // Add the new songs in front of the current index in the shuffled mapping and in front + // of the analogous list song in the ordered mapping. + val orderedIndex = orderedMapping.indexOf(shuffledMapping[index]) + orderedMapping.addAll(orderedIndex + 1, heapIndices) + shuffledMapping.addAll(index + 1, heapIndices) + } else { + // Add the new song in front of the current index in the ordered mapping. + orderedMapping.addAll(index + 1, heapIndices) + } + check() + return ChangeResult.MAPPING + } + + /** + * Add [Song]s to the end of the queue. Will start playback if nothing is playing. + * @param songs The [Song]s to add. + * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there + * was no prior playback and these enqueued [Song]s start new playback. + */ + fun addToQueue(songs: List): ChangeResult { + if (orderedMapping.isEmpty()) { + // No playback, start playing these songs. + start(songs[0], songs, false) + return ChangeResult.SONG + } + + val heapIndices = songs.map(::addSongToHeap) + // Can simple append the new songs to the end of both mappings. + orderedMapping.addAll(heapIndices) + if (shuffledMapping.isNotEmpty()) { + shuffledMapping.addAll(heapIndices) + } + check() + return ChangeResult.MAPPING + } + + /** + * Move a [Song] at the given position to a new position. + * @param src The position of the [Song] to move. + * @param dst The destination position of the [Song]. + * @return [ChangeResult.MAPPING] if the move occurred after the current index, + * [ChangeResult.INDEX] if the move occurred before or at the current index, requiring it to be + * mutated. + */ + fun move(src: Int, dst: Int): ChangeResult { + if (shuffledMapping.isNotEmpty()) { + // Move songs only in the shuffled mapping. There is no sane analogous form of + // this for the ordered mapping. + shuffledMapping.add(dst, shuffledMapping.removeAt(src)) + } else { + // Move songs in the ordered mapping. + orderedMapping.add(dst, orderedMapping.removeAt(src)) + } + + when (index) { + // We are moving the currently playing song, correct the index to it's new position. + src -> index = dst + // We have moved an song from behind the playing song to in front, shift back. + in (src + 1)..dst -> index -= 1 + // We have moved an song from in front of the playing song to behind, shift forward. + in dst until src -> index += 1 + else -> { + // Nothing to do. + check() + return ChangeResult.MAPPING + } + } + check() + return ChangeResult.INDEX + } + + /** + * Remove a [Song] at the given position. + * @param at The position of the [Song] to remove. + * @return [ChangeResult.MAPPING] if the removed [Song] was after the current index, + * [ChangeResult.INDEX] if the removed [Song] was before the current index, and + * [ChangeResult.SONG] if the currently playing [Song] was removed. + */ + fun remove(at: Int): ChangeResult { + if (shuffledMapping.isNotEmpty()) { + // Remove the specified index in the shuffled mapping and the analogous song in the + // ordered mapping. + orderedMapping.removeAt(orderedMapping.indexOf(shuffledMapping[at])) + shuffledMapping.removeAt(at) + } else { + // Remove the specified index in the shuffled mapping + orderedMapping.removeAt(at) + } + + // Note: We do not clear songs out from the heap, as that would require the backing data + // of the player to be completely invalidated. It's generally easier to not remove the + // song and retain player state consistency. + + val result = + when { + // We just removed the currently playing song. + index == at -> ChangeResult.SONG + // Index was ahead of removed song, shift back to preserve consistency. + index > at -> { + index -= 1 + ChangeResult.INDEX + } + // Nothing to do + else -> ChangeResult.MAPPING + } + check() + return result + } + + /** + * Convert the current state of this instance into a [SavedState]. + * @return A new [SavedState] reflecting the exact state of the queue when called. + */ + fun toSavedState() = + currentSong?.let { song -> + SavedState( + heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) + } + + /** + * Update this instance from the given [SavedState]. + * @param savedState A [SavedState] with a valid queue representation. + */ + fun applySavedState(savedState: SavedState) { + val adjustments = mutableListOf() + var currentShift = 0 + for (song in savedState.heap) { + if (song != null) { + adjustments.add(currentShift) + } else { + adjustments.add(null) + currentShift -= 1 + } + } + + heap = savedState.heap.filterNotNull().toMutableList() + orderedMapping = + savedState.orderedMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + shuffledMapping = + savedState.shuffledMapping.mapNotNullTo(mutableListOf()) { heapIndex -> + adjustments[heapIndex]?.let { heapIndex + it } + } + + // Make sure we re-align the index to point to the previously playing song. + index = savedState.index + while (currentSong?.uid != savedState.songUid && index > -1) { + index-- + } + check() + } + + private fun addSongToHeap(song: Song): Int { + // We want to first try to see if there are any "orphaned" songs in the queue + // that we can re-use. This way, we can reduce the memory used up by songs that + // were previously removed from the queue. + val currentMapping = orderedMapping + if (orderedMapping.isNotEmpty()) { + // While we could iterate through the queue and then check the mapping, it's + // faster if we first check the queue for all instances of this song, and then + // do a exclusion of this set of indices with the current mapping in order to + // obtain the orphaned songs. + val orphanCandidates = mutableSetOf() + for (entry in heap.withIndex()) { + if (entry.value == song) { + orphanCandidates.add(entry.index) + } + } + orphanCandidates.removeAll(currentMapping.toSet()) + if (orphanCandidates.isNotEmpty()) { + // There are orphaned songs, return the first one we find. + return orphanCandidates.first() + } + } + // Nothing to re-use, add this song to the queue + heap.add(song) + return heap.lastIndex + } + + private fun check() { + check(!(heap.isEmpty() && (orderedMapping.isNotEmpty() || shuffledMapping.isNotEmpty()))) { + "Queue inconsistency detected: Empty heap with non-empty mappings" + + "[ordered: ${orderedMapping.size}, shuffled: ${shuffledMapping.size}]" + } + + check(shuffledMapping.isEmpty() || orderedMapping.size == shuffledMapping.size) { + "Queue inconsistency detected: Ordered mapping size ${orderedMapping.size} " + + "!= Shuffled mapping size ${shuffledMapping.size}" + } + + check(orderedMapping.all { it in heap.indices }) { + "Queue inconsistency detected: Ordered mapping indices out of heap bounds" + } + + check(shuffledMapping.all { it in heap.indices }) { + "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" + } + } + + /** + * An immutable representation of the queue state. + * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with + * null values to represent [Song]s that were "lost" from the heap without having to change + * other values. + * @param orderedMapping The mapping of the [heap] to an ordered queue. + * @param shuffledMapping The mapping of the [heap] to a shuffled queue. + * @param index The index of the currently playing [Song] at the time of serialization. + * @param songUid The [Music.UID] of the [Song] that was originally at [index]. + */ + class SavedState( + val heap: List, + val orderedMapping: List, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, + ) { + /** + * Remaps the [heap] of this instance based on the given mapping function and copies it into + * a new [SavedState]. + * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This + * **MUST** be the same size as the original heap. [Song] instances that could not be + * converted should be replaced with null in the new heap. + * @throws IllegalStateException If the invariant specified by [transform] is violated. + */ + inline fun remap(transform: (Song?) -> Song?) = + SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) + } + + /** + * Represents the possible changes that can occur during certain queue mutation events. The + * precise meanings of these differ somewhat depending on the type of mutation done. + */ + enum class ChangeResult { + /** Only the mapping has changed. */ + MAPPING, + /** The mapping has changed, and the index also changed to align with it. */ + INDEX, + /** + * The current song has changed, possibly alongside the mapping and index depending on the + * context. + */ + SONG + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt index d1d28c3c3..090c81162 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager class MediaButtonReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val playbackManager = PlaybackStateManager.getInstance() - if (playbackManager.song != null) { + if (playbackManager.queue.currentSong != null) { // We have a song, so we can assume that the service will start a foreground state. // At least, I hope. Again, *this is why we don't do this*. I cannot describe how // stupid this is with the state of foreground services on modern android. One diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 863d71b6b..bd6900c51 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.system import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -31,13 +30,15 @@ import androidx.media.session.MediaButtonReceiver import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD /** @@ -50,7 +51,8 @@ import org.oxycblt.auxio.util.logD class MediaSessionComponent(private val context: Context, private val listener: Listener) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener, - SharedPreferences.OnSharedPreferenceChangeListener { + ImageSettings.Listener, + PlaybackSettings.Listener { private val mediaSession = MediaSessionCompat(context, context.packageName).apply { isActive = true @@ -58,13 +60,14 @@ class MediaSessionComponent(private val context: Context, private val listener: } private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val playbackSettings = PlaybackSettings.from(context) private val notification = NotificationComponent(context, mediaSession.sessionToken) private val provider = BitmapProvider(context) init { playbackManager.addListener(this) + playbackSettings.registerListener(this) mediaSession.setCallback(this) } @@ -82,7 +85,7 @@ class MediaSessionComponent(private val context: Context, private val listener: */ fun release() { provider.release() - settings.removeListener(this) + playbackSettings.unregisterListener(this) playbackManager.removeListener(this) mediaSession.apply { isActive = false @@ -92,22 +95,38 @@ class MediaSessionComponent(private val context: Context, private val listener: // --- PLAYBACKSTATEMANAGER OVERRIDES --- - override fun onIndexMoved(index: Int) { - updateMediaMetadata(playbackManager.song, playbackManager.parent) + override fun onIndexMoved(queue: Queue) { + updateMediaMetadata(queue.currentSong, playbackManager.parent) invalidateSessionState() } - override fun onQueueChanged(queue: List) { + override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) { updateQueue(queue) + when (change) { + // Nothing special to do with mapping changes. + Queue.ChangeResult.MAPPING -> {} + // Index changed, ensure playback state's index changes. + Queue.ChangeResult.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + Queue.ChangeResult.SONG -> + updateMediaMetadata(queue.currentSong, playbackManager.parent) + } } - override fun onQueueReworked(index: Int, queue: List) { + override fun onQueueReordered(queue: Queue) { updateQueue(queue) invalidateSessionState() + mediaSession.setShuffleMode( + if (queue.isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() } - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) { - updateMediaMetadata(playbackManager.song, parent) + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + updateMediaMetadata(queue.currentSong, parent) updateQueue(queue) invalidateSessionState() } @@ -131,25 +150,16 @@ class MediaSessionComponent(private val context: Context, private val listener: invalidateSecondaryAction() } - override fun onShuffledChanged(isShuffled: Boolean) { - mediaSession.setShuffleMode( - if (isShuffled) { - PlaybackStateCompat.SHUFFLE_MODE_ALL - } else { - PlaybackStateCompat.SHUFFLE_MODE_NONE - }) - - invalidateSecondaryAction() - } - // --- SETTINGS OVERRIDES --- - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - context.getString(R.string.set_key_cover_mode) -> - updateMediaMetadata(playbackManager.song, playbackManager.parent) - context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction() - } + override fun onCoverModeChanged() { + // Need to reload the metadata cover. + updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent) + } + + override fun onNotificationActionChanged() { + // Need to re-load the action shown in the notification. + invalidateSecondaryAction() } // --- MEDIASESSION OVERRIDES --- @@ -219,16 +229,13 @@ class MediaSessionComponent(private val context: Context, private val listener: } override fun onSetShuffleMode(shuffleMode: Int) { - playbackManager.reshuffle( + playbackManager.reorder( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP, - settings) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } override fun onSkipToQueueItem(id: Long) { - if (id in playbackManager.queue.indices) { - playbackManager.goto(id.toInt()) - } + playbackManager.goto(id.toInt()) } override fun onCustomAction(action: String?, extras: Bundle?) { @@ -318,9 +325,9 @@ class MediaSessionComponent(private val context: Context, private val listener: * Upload a new queue to the [MediaSessionCompat]. * @param queue The current queue to upload. */ - private fun updateQueue(queue: List) { + private fun updateQueue(queue: Queue) { val queueItems = - queue.mapIndexed { i, song -> + queue.resolve().mapIndexed { i, song -> val description = MediaDescriptionCompat.Builder() // Media ID should not be the item index but rather the UID, @@ -350,18 +357,18 @@ class MediaSessionComponent(private val context: Context, private val listener: .intoPlaybackState(PlaybackStateCompat.Builder()) .setActions(ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. - .setActiveQueueItemId(playbackManager.index.toLong()) + .setActiveQueueItemId(playbackManager.queue.index.toLong()) // Android 13+ relies on custom actions in the notification. // Add the secondary action (either repeat/shuffle depending on the configuration) val secondaryAction = - when (settings.playbackNotificationAction) { + when (playbackSettings.notificationAction) { ActionMode.SHUFFLE -> PlaybackStateCompat.CustomAction.Builder( PlaybackService.ACTION_INVERT_SHUFFLE, context.getString(R.string.desc_shuffle), - if (playbackManager.isShuffled) { + if (playbackManager.queue.isShuffled) { R.drawable.ic_shuffle_on_24 } else { R.drawable.ic_shuffle_off_24 @@ -390,8 +397,8 @@ class MediaSessionComponent(private val context: Context, private val listener: private fun invalidateSecondaryAction() { invalidateSessionState() - when (settings.playbackNotificationAction) { - ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled) + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled) else -> notification.updateRepeatMode(playbackManager.repeatMode) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index c615a27ab..08b820a5e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -43,15 +43,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateDatabase import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundManager -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider @@ -91,7 +93,8 @@ class PlaybackService : // Managers private val playbackManager = PlaybackStateManager.getInstance() private val musicStore = MusicStore.getInstance() - private lateinit var settings: Settings + private lateinit var musicSettings: MusicSettings + private lateinit var playbackSettings: PlaybackSettings // State private lateinit var foregroundManager: ForegroundManager @@ -142,7 +145,8 @@ class PlaybackService : .also { it.addListener(this) } replayGainProcessor.addToListeners(player) // Initialize the core service components - settings = Settings(this) + musicSettings = MusicSettings.from(this) + playbackSettings = PlaybackSettings.from(this) foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. @@ -212,7 +216,7 @@ class PlaybackService : get() = player.audioSessionId override val shouldRewindWithPrev: Boolean - get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD + get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD override fun getState(durationMs: Long) = InternalPlayer.State.from( @@ -285,7 +289,7 @@ class PlaybackService : if (playbackManager.repeatMode == RepeatMode.TRACK) { playbackManager.rewind() // May be configured to pause when we repeat a track. - if (settings.pauseOnRepeat) { + if (playbackSettings.pauseOnRepeat) { playbackManager.setPlaying(false) } } else { @@ -302,7 +306,7 @@ class PlaybackService : // --- MUSICSTORE OVERRIDES --- - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) @@ -351,12 +355,16 @@ class PlaybackService : } // Shuffle all -> Start new playback from all songs is InternalPlayer.Action.ShuffleAll -> { - playbackManager.play(null, null, settings, true) + playbackManager.play(null, null, musicSettings.songSort.songs(library.songs), true) } // Open -> Try to find the Song for the given file and then play it from all songs is InternalPlayer.Action.Open -> { library.findSongForUri(application, action.uri)?.let { song -> - playbackManager.play(song, null, settings) + playbackManager.play( + song, + null, + musicSettings.songSort.songs(library.songs), + playbackManager.queue.isShuffled && playbackSettings.keepShuffle) } } } @@ -411,8 +419,7 @@ class PlaybackService : playbackManager.setPlaying(!playbackManager.playerState.isPlaying) ACTION_INC_REPEAT_MODE -> playbackManager.repeatMode = playbackManager.repeatMode.increment() - ACTION_INVERT_SHUFFLE -> - playbackManager.reshuffle(!playbackManager.isShuffled, settings) + ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled) ACTION_SKIP_PREV -> playbackManager.prev() ACTION_SKIP_NEXT -> playbackManager.next() ACTION_EXIT -> { @@ -427,8 +434,8 @@ class PlaybackService : // ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached, // which would result in unexpected playback. Work around it by dropping the first // call to this function, which should come from that Intent. - if (settings.headsetAutoplay && - playbackManager.song != null && + if (playbackSettings.headsetAutoplay && + playbackManager.queue.currentSong != null && initialHeadsetPlugEventHandled) { logD("Device connected, resuming") playbackManager.setPlaying(true) @@ -436,7 +443,7 @@ class PlaybackService : } private fun pauseFromHeadsetPlug() { - if (playbackManager.song != null) { + if (playbackManager.queue.currentSong != null) { logD("Device disconnected, pausing") playbackManager.setPlaying(false) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index a1260859b..62c157bd8 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -18,29 +18,28 @@ package org.oxycblt.auxio.search import android.view.ViewGroup -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.* +import org.oxycblt.auxio.list.adapter.BasicListInstructions +import org.oxycblt.auxio.list.adapter.ListDiffer +import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter +import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.recycler.* -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.util.logD /** * An adapter that displays search results. * @param listener An [SelectableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter(), AuxioRecyclerView.SpanSizeLookup { - private val differ = AsyncListDiffer(this, DIFF_CALLBACK) - - override val currentList: List - get() = differ.currentList +class SearchAdapter(private val listener: SelectableListListener) : + SelectionIndicatorAdapter( + ListDiffer.Async(DIFF_CALLBACK)), + AuxioRecyclerView.SpanSizeLookup { override fun getItemViewType(position: Int) = - when (differ.currentList[position]) { + when (getItem(position)) { is Song -> SongViewHolder.VIEW_TYPE is Album -> AlbumViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE @@ -60,7 +59,8 @@ class SearchAdapter(private val listener: SelectableListListener) : } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (val item = differ.currentList[position]) { + logD(position) + when (val item = getItem(position)) { is Song -> (holder as SongViewHolder).bind(item, listener) is Album -> (holder as AlbumViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) @@ -69,22 +69,21 @@ class SearchAdapter(private val listener: SelectableListListener) : } } - override fun isItemFullWidth(position: Int) = differ.currentList[position] is Header + override fun isItemFullWidth(position: Int) = getItem(position) is Header /** - * Asynchronously update the list with new items. Assumes that the list only contains supported - * data.. - * @param newList The new [Item]s for the adapter to display. - * @param callback A block called when the asynchronous update is completed. + * Make sure that the top header has a correctly configured divider visibility. This would + * normally be automatically done by the differ, but that results in a strange animation. */ - fun submitList(newList: List, callback: () -> Unit) { - differ.submitList(newList, callback) + fun pokeDividers() { + notifyItemChanged(0, PAYLOAD_UPDATE_DIVIDER) } private companion object { + val PAYLOAD_UPDATE_DIVIDER = 102249124 /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = - object : SimpleItemCallback() { + object : SimpleDiffCallback() { override fun areContentsTheSame(oldItem: Item, newItem: Item) = when { oldItem is Song && newItem is Song -> diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index f9055e96d..eeaafb214 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -31,14 +31,13 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* /** @@ -50,7 +49,7 @@ import org.oxycblt.auxio.util.* * * @author Alexander Capehart (OxygenCobalt) */ -class SearchFragment : ListFragment() { +class SearchFragment : ListFragment() { private val searchModel: SearchViewModel by androidViewModels() private val searchAdapter = SearchAdapter(this) private var imm: InputMethodManager? = null @@ -134,26 +133,19 @@ class SearchFragment : ListFragment() { return false } - override fun onRealClick(music: Music) { - when (music) { - is Song -> - when (Settings(requireContext()).libPlaybackMode) { - MusicMode.SONGS -> playbackModel.playFromAll(music) - MusicMode.ALBUMS -> playbackModel.playFromAlbum(music) - MusicMode.ARTISTS -> playbackModel.playFromArtist(music) - MusicMode.GENRES -> playbackModel.playFromGenre(music) - } - is MusicParent -> navModel.exploreNavigateTo(music) + override fun onRealClick(item: Music) { + when (item) { + is MusicParent -> navModel.exploreNavigateTo(item) + is Song -> playbackModel.playFrom(item, searchModel.playbackMode) } } - override fun onOpenMenu(item: Item, anchor: View) { + override fun onOpenMenu(item: Music, anchor: View) { when (item) { is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item) is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) is Genre -> openMusicMenu(anchor, R.menu.menu_artist_actions, item) - else -> logW("Unexpected datatype when opening menu: ${item::class.java}") } } @@ -162,16 +154,17 @@ class SearchFragment : ListFragment() { // Don't show the RecyclerView (and it's stray overscroll effects) when there // are no results. binding.searchRecycler.isInvisible = results.isEmpty() - searchAdapter.submitList(results.toMutableList()) { + searchAdapter.submitList(results.toMutableList(), BasicListInstructions.DIFF) { // I would make it so that the position is only scrolled back to the top when // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. binding.searchRecycler.scrollToPosition(0) + searchAdapter.pokeDividers() } } private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { - searchAdapter.setPlayingItem(parent ?: song, isPlaying) + searchAdapter.setPlaying(parent ?: song, isPlaying) } private fun handleNavigation(item: Music?) { @@ -189,7 +182,7 @@ class SearchFragment : ListFragment() { } private fun updateSelection(selected: List) { - searchAdapter.setSelectedItems(selected) + searchAdapter.setSelected(selected.toSet()) if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) && selected.isNotEmpty()) { // Make selection of obscured items easier by hiding the keyboard. diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt new file mode 100644 index 000000000..881bc8940 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.search + +import android.content.Context +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.settings.Settings + +/** + * User configuration specific to the search UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface SearchSettings : Settings { + /** The type of Music the search view is currently filtering to. */ + var searchFilterMode: MusicMode? + + private class Real(context: Context) : Settings.Real(context), SearchSettings { + override var searchFilterMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_search_filter), Int.MIN_VALUE)) + set(value) { + sharedPreferences.edit { + putInt( + getString(R.string.set_key_search_filter), value?.intCode ?: Int.MIN_VALUE) + apply() + } + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): SearchSettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 72ea04fae..9341a7390 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -30,11 +30,11 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicMode +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.Sort +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD @@ -45,7 +45,8 @@ import org.oxycblt.auxio.util.logD class SearchViewModel(application: Application) : AndroidViewModel(application), MusicStore.Listener { private val musicStore = MusicStore.getInstance() - private val settings = Settings(context) + private val searchSettings = SearchSettings.from(application) + private val playbackSettings = PlaybackSettings.from(application) private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -54,6 +55,10 @@ class SearchViewModel(application: Application) : val searchResults: StateFlow> get() = _searchResults + /** The [MusicMode] to use when playing a [Song] from the UI. */ + val playbackMode: MusicMode + get() = playbackSettings.inListPlaybackMode + init { musicStore.addListener(this) } @@ -63,7 +68,7 @@ class SearchViewModel(application: Application) : musicStore.removeListener(this) } - override fun onLibraryChanged(library: MusicStore.Library?) { + override fun onLibraryChanged(library: Library?) { if (library != null) { // Make sure our query is up to date with the music library. search(lastQuery) @@ -96,9 +101,9 @@ class SearchViewModel(application: Application) : } } - private fun searchImpl(library: MusicStore.Library, query: String): List { + private fun searchImpl(library: Library, query: String): List { val sort = Sort(Sort.Mode.ByName, true) - val filterMode = settings.searchFilterMode + val filterMode = searchSettings.searchFilterMode val results = mutableListOf() // Note: A null filter mode maps to the "All" filter option, hence the check. @@ -183,7 +188,7 @@ class SearchViewModel(application: Application) : */ @IdRes fun getFilterOptionId() = - when (settings.searchFilterMode) { + when (searchSettings.searchFilterMode) { MusicMode.SONGS -> R.id.option_filter_songs MusicMode.ALBUMS -> R.id.option_filter_albums MusicMode.ARTISTS -> R.id.option_filter_artists @@ -208,7 +213,7 @@ class SearchViewModel(application: Application) : else -> error("Invalid option ID provided") } logD("Updating filter mode to $newFilterMode") - settings.searchFilterMode = newFilterMode + searchSettings.searchFilterMode = newFilterMode search(lastQuery) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index bb4d9109b..aa94552d8 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -123,7 +123,7 @@ class AboutFragment : ViewBindingFragment() { if (pkgName == "android") { // No default browser [Must open app chooser, may not be supported] openAppChooser(browserIntent) - } else { + } else try { browserIntent.setPackage(pkgName) startActivity(browserIntent) @@ -132,7 +132,6 @@ class AboutFragment : ViewBindingFragment() { browserIntent.setPackage(null) openAppChooser(browserIntent) } - } } else { // No app installed to open the link context.showToast(R.string.err_no_app) diff --git a/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt new file mode 100644 index 000000000..547c245e3 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/BasePreferenceFragment.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.XmlRes +import androidx.appcompat.widget.Toolbar +import androidx.core.view.updatePadding +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.children +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.transition.MaterialSharedAxis +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.ui.IntListPreference +import org.oxycblt.auxio.settings.ui.IntListPreferenceDialog +import org.oxycblt.auxio.settings.ui.PreferenceHeaderItemDecoration +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.systemBarInsetsCompat + +/** + * Shared [PreferenceFragmentCompat] used across all preference screens. + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class BasePreferenceFragment(@XmlRes private val screen: Int) : + PreferenceFragmentCompat() { + /** + * Called when the UI entry of a given [Preference] needs to be configured. + * @param preference The [Preference] to configure. + */ + open fun onSetupPreference(preference: Preference) {} + + /** + * Called when an arbitrary [WrappedDialogPreference] needs to be opened. + * @param preference The [WrappedDialogPreference] to open. + */ + open fun onOpenDialogPreference(preference: WrappedDialogPreference) {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById(R.id.preferences_appbar).liftOnScrollTargetViewId = + androidx.preference.R.id.recycler_view + view.findViewById(R.id.preferences_toolbar).apply { + title = preferenceScreen.title + setNavigationOnClickListener { findNavController().navigateUp() } + } + + preferenceManager.onDisplayPreferenceDialogListener = this + preferenceScreen.children.forEach(::setupPreference) + + logD("Fragment created") + } + + override fun onCreateRecyclerView( + inflater: LayoutInflater, + parent: ViewGroup, + savedInstanceState: Bundle? + ) = + super.onCreateRecyclerView(inflater, parent, savedInstanceState).apply { + clipToPadding = false + addItemDecoration(PreferenceHeaderItemDecoration(context)) + setOnApplyWindowInsetsListener { _, insets -> + updatePadding(bottom = insets.systemBarInsetsCompat.bottom) + insets + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(screen, rootKey) + } + + override fun onDisplayPreferenceDialog(preference: Preference) { + when (preference) { + is IntListPreference -> { + // Copy the built-in preference dialog launching code into our project so + // we can automatically use the provided preference class. + val dialog = IntListPreferenceDialog.from(preference) + @Suppress("Deprecation") dialog.setTargetFragment(this, 0) + dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) + } + is WrappedDialogPreference -> { + // These dialog preferences cannot launch on their own, delegate to + // implementations. + onOpenDialogPreference(preference) + } + else -> super.onDisplayPreferenceDialog(preference) + } + } + + private fun setupPreference(preference: Preference) { + if (!preference.isVisible) { + // Nothing to do. + return + } + + if (preference is PreferenceCategory) { + preference.children.forEach(::setupPreference) + return + } + + onSetupPreference(preference) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt new file mode 100644 index 000000000..4bb3acecb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2021 Auxio Project + * + * 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.settings + +import android.os.Bundle +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.transition.MaterialFadeThrough +import com.google.android.material.transition.MaterialSharedAxis +import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.util.androidActivityViewModels +import org.oxycblt.auxio.util.showToast + +/** + * The [PreferenceFragmentCompat] that displays the root settings list. + * @author Alexander Capehart (OxygenCobalt) + */ +class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { + private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enterTransition = MaterialFadeThrough() + returnTransition = MaterialFadeThrough() + exitTransition = MaterialFadeThrough() + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { + if (preference.key == getString(R.string.set_key_music_dirs)) { + findNavController().navigate(RootPreferenceFragmentDirections.goToMusicDirsDialog()) + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + // Hook generic preferences to their specified preferences + // TODO: These seem like good things to put into a side navigation view, if I choose to + // do one. + when (preference.key) { + getString(R.string.set_key_ui) -> { + findNavController().navigate(RootPreferenceFragmentDirections.goToUiPreferences()) + } + getString(R.string.set_key_personalize) -> { + findNavController() + .navigate(RootPreferenceFragmentDirections.goToPersonalizePreferences()) + } + getString(R.string.set_key_music) -> { + findNavController() + .navigate(RootPreferenceFragmentDirections.goToMusicPreferences()) + } + getString(R.string.set_key_audio) -> { + findNavController() + .navigate(RootPreferenceFragmentDirections.goToAudioPreferences()) + } + getString(R.string.set_key_reindex) -> musicModel.refresh() + getString(R.string.set_key_rescan) -> musicModel.rescan() + getString(R.string.set_key_save_state) -> { + playbackModel.savePlaybackState { saved -> + // Use the nullable context, as we could try to show a toast when this + // fragment is no longer attached. + if (saved) { + context?.showToast(R.string.lbl_state_saved) + } else { + context?.showToast(R.string.err_did_not_save) + } + } + } + getString(R.string.set_key_wipe_state) -> { + playbackModel.wipePlaybackState { wiped -> + if (wiped) { + // Use the nullable context, as we could try to show a toast when this + // fragment is no longer attached. + context?.showToast(R.string.lbl_state_wiped) + } else { + context?.showToast(R.string.err_did_not_wipe) + } + } + } + getString(R.string.set_key_restore_state) -> + playbackModel.tryRestorePlaybackState { restored -> + if (restored) { + // Use the nullable context, as we could try to show a toast when this + // fragment is no longer attached. + context?.showToast(R.string.lbl_state_restored) + } else { + context?.showToast(R.string.err_did_not_restore) + } + } + else -> return super.onPreferenceTreeClick(preference) + } + + return true + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 2412b5ee9..92a81fa26 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Auxio Project + * Copyright (c) 2023 Auxio Project * * 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 @@ -19,446 +19,80 @@ package org.oxycblt.auxio.settings import android.content.Context import android.content.SharedPreferences -import android.content.SharedPreferences.OnSharedPreferenceChangeListener -import android.os.Build -import android.os.storage.StorageManager -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.edit +import androidx.annotation.StringRes import androidx.preference.PreferenceManager -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.image.CoverMode -import org.oxycblt.auxio.music.MusicMode -import org.oxycblt.auxio.music.Sort -import org.oxycblt.auxio.music.filesystem.Directory -import org.oxycblt.auxio.music.filesystem.MusicDirectories -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.replaygain.ReplayGainMode -import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp -import org.oxycblt.auxio.ui.accent.Accent -import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull /** - * A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member - * mutability is dependent on how they are used in app. Immutable members are often only modified by - * the preferences view, while mutable members are modified elsewhere. + * Abstract user configuration information. This interface has no functionality whatsoever. Concrete + * implementations should be preferred instead. * @author Alexander Capehart (OxygenCobalt) */ -class Settings(private val context: Context) { - private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - +interface Settings { /** - * Migrate any settings from an old version into their modern counterparts. This can cause data - * loss depending on the feasibility of a migration. + * Migrate any settings fields from older versions into their new counterparts. + * @throws NotImplementedError If there is nothing to migrate. */ fun migrate() { - if (inner.contains(OldKeys.KEY_ACCENT3)) { - logD("Migrating ${OldKeys.KEY_ACCENT3}") - - var accent = inner.getInt(OldKeys.KEY_ACCENT3, 5) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Accents were previously frozen as soon as the OS was updated to android twelve, - // as dynamic colors were enabled by default. This is no longer the case, so we need - // to re-update the setting to dynamic colors here. - accent = 16 - } - - inner.edit { - putInt(context.getString(R.string.set_key_accent), accent) - remove(OldKeys.KEY_ACCENT3) - apply() - } - } - - if (inner.contains(OldKeys.KEY_SHOW_COVERS) || inner.contains(OldKeys.KEY_QUALITY_COVERS)) { - logD("Migrating cover settings") - - val mode = - when { - !inner.getBoolean(OldKeys.KEY_SHOW_COVERS, true) -> CoverMode.OFF - !inner.getBoolean(OldKeys.KEY_QUALITY_COVERS, true) -> CoverMode.MEDIA_STORE - else -> CoverMode.QUALITY - } - - inner.edit { - putInt(context.getString(R.string.set_key_cover_mode), mode.intCode) - remove(OldKeys.KEY_SHOW_COVERS) - remove(OldKeys.KEY_QUALITY_COVERS) - } - } - - if (inner.contains(OldKeys.KEY_ALT_NOTIF_ACTION)) { - logD("Migrating ${OldKeys.KEY_ALT_NOTIF_ACTION}") - - val mode = - if (inner.getBoolean(OldKeys.KEY_ALT_NOTIF_ACTION, false)) { - ActionMode.SHUFFLE - } else { - ActionMode.REPEAT - } - - inner.edit { - putInt(context.getString(R.string.set_key_notif_action), mode.intCode) - remove(OldKeys.KEY_ALT_NOTIF_ACTION) - apply() - } - } - - fun Int.migratePlaybackMode() = - when (this) { - // Convert PlaybackMode into MusicMode - IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS - IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS - IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS - IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES - else -> null - } - - if (inner.contains(OldKeys.KEY_LIB_PLAYBACK_MODE)) { - logD("Migrating ${OldKeys.KEY_LIB_PLAYBACK_MODE}") - - val mode = - inner - .getInt(OldKeys.KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) - .migratePlaybackMode() - ?: MusicMode.SONGS - - inner.edit { - putInt(context.getString(R.string.set_key_library_song_playback_mode), mode.intCode) - remove(OldKeys.KEY_LIB_PLAYBACK_MODE) - apply() - } - } - - if (inner.contains(OldKeys.KEY_DETAIL_PLAYBACK_MODE)) { - logD("Migrating ${OldKeys.KEY_DETAIL_PLAYBACK_MODE}") - - val mode = - inner.getInt(OldKeys.KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE).migratePlaybackMode() - - inner.edit { - putInt( - context.getString(R.string.set_key_detail_song_playback_mode), - mode?.intCode ?: Int.MIN_VALUE) - remove(OldKeys.KEY_DETAIL_PLAYBACK_MODE) - apply() - } - } + throw NotImplementedError() } /** - * Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates. - * @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add. + * Add a listener to monitor for settings updates. Will do nothing if + * @param listener The listener to add. */ - fun addListener(listener: OnSharedPreferenceChangeListener) { - inner.registerOnSharedPreferenceChangeListener(listener) - } + fun registerListener(listener: L) /** - * Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further - * settings updates from being sent to ti.t + * Unregister a listener, preventing any further settings updates from being sent to it. + * @param listener The listener to unregister, must be the same as the current listener. */ - fun removeListener(listener: OnSharedPreferenceChangeListener) { - inner.unregisterOnSharedPreferenceChangeListener(listener) - } - - // --- VALUES --- - - /** The current theme. Represented by the [AppCompatDelegate] constants. */ - val theme: Int - get() = - inner.getInt( - context.getString(R.string.set_key_theme), - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - - /** Whether to use a black background when a dark theme is currently used. */ - val useBlackTheme: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_black_theme), false) - - /** The current [Accent] (Color Scheme). */ - var accent: Accent - get() = - Accent.from(inner.getInt(context.getString(R.string.set_key_accent), Accent.DEFAULT)) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_accent), value.index) - apply() - } - } - - /** The tabs to show in the home UI. */ - var libTabs: Array - get() = - Tab.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_tabs), Tab.SEQUENCE_DEFAULT)) - ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_tabs), Tab.toIntCode(value)) - apply() - } - } - - /** Whether to hide artists considered "collaborators" from the home UI. */ - val shouldHideCollaborators: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_hide_collaborators), false) - - /** Whether to round additional UI elements that require album covers to be rounded. */ - val roundMode: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_round_mode), false) - - /** The action to display on the playback bar. */ - val playbackBarAction: ActionMode - get() = - ActionMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_bar_action), Int.MIN_VALUE)) - ?: ActionMode.NEXT - - /** The action to display in the playback notification. */ - val playbackNotificationAction: ActionMode - get() = - ActionMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_notif_action), Int.MIN_VALUE)) - ?: ActionMode.REPEAT - - /** Whether to start playback when a headset is plugged in. */ - val headsetAutoplay: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_headset_autoplay), false) - - /** The current ReplayGain configuration. */ - val replayGainMode: ReplayGainMode - get() = - ReplayGainMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) - ?: ReplayGainMode.DYNAMIC - - /** The current ReplayGain pre-amp configuration. */ - var replayGainPreAmp: ReplayGainPreAmp - get() = - ReplayGainPreAmp( - inner.getFloat(context.getString(R.string.set_key_pre_amp_with), 0f), - inner.getFloat(context.getString(R.string.set_key_pre_amp_without), 0f)) - set(value) { - inner.edit { - putFloat(context.getString(R.string.set_key_pre_amp_with), value.with) - putFloat(context.getString(R.string.set_key_pre_amp_without), value.without) - apply() - } - } - - /** What MusicParent item to play from when a Song is played from the home view. */ - val libPlaybackMode: MusicMode - get() = - MusicMode.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_library_song_playback_mode), Int.MIN_VALUE)) - ?: MusicMode.SONGS + fun unregisterListener(listener: L) /** - * What MusicParent item to play from when a Song is played from the detail view. Will be null - * if configured to play from the currently shown item. + * A framework-backed [Settings] implementation. + * @param context [Context] required. */ - val detailPlaybackMode: MusicMode? - get() = - MusicMode.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_detail_song_playback_mode), Int.MIN_VALUE)) + abstract class Real(private val context: Context) : + Settings, SharedPreferences.OnSharedPreferenceChangeListener { + protected val sharedPreferences: SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - /** Whether to keep shuffle on when playing a new Song. */ - val keepShuffle: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_keep_shuffle), true) + /** @see [Context.getString] */ + protected fun getString(@StringRes stringRes: Int) = context.getString(stringRes) - /** Whether to rewind when the skip previous button is pressed before skipping back. */ - val rewindWithPrev: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_rewind_prev), true) + private var listener: L? = null - /** Whether a song should pause after every repeat. */ - val pauseOnRepeat: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false) - - /** Whether to be actively watching for changes in the music library. */ - val shouldBeObserving: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_observing), false) - - /** The strategy used when loading album covers. */ - val coverMode: CoverMode - get() = - CoverMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) - ?: CoverMode.MEDIA_STORE - - /** Whether to exclude non-music audio files from the music library. */ - val excludeNonMusic: Boolean - get() = inner.getBoolean(context.getString(R.string.set_key_exclude_non_music), true) - - /** - * Set the configuration on how to handle particular directories in the music library. - * @param storageManager [StorageManager] required to parse directories. - * @return The [MusicDirectories] configuration. - */ - fun getMusicDirs(storageManager: StorageManager): MusicDirectories { - val dirs = - (inner.getStringSet(context.getString(R.string.set_key_music_dirs), null) ?: emptySet()) - .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } - return MusicDirectories( - dirs, inner.getBoolean(context.getString(R.string.set_key_music_dirs_include), false)) - } - - /** - * Set the configuration on how to handle particular directories in the music library. - * @param musicDirs The new [MusicDirectories] configuration. - */ - fun setMusicDirs(musicDirs: MusicDirectories) { - inner.edit { - putStringSet( - context.getString(R.string.set_key_music_dirs), - musicDirs.dirs.map(Directory::toDocumentTreeUri).toSet()) - putBoolean( - context.getString(R.string.set_key_music_dirs_include), musicDirs.shouldInclude) - apply() - } - } - - /** - * A string of characters representing the desired separator characters to denote multi-value - * tags. - */ - var musicSeparators: String? - // Differ from convention and store a string of separator characters instead of an int - // code. This makes it easier to use in Regexes and makes it more extendable. - get() = - inner.getString(context.getString(R.string.set_key_separators), null)?.ifEmpty { null } - set(value) { - inner.edit { - putString(context.getString(R.string.set_key_separators), value?.ifEmpty { null }) - apply() + override fun registerListener(listener: L) { + if (this.listener == null) { + // Registering a listener when it was null prior, attach the callback. + sharedPreferences.registerOnSharedPreferenceChangeListener(this) } + this.listener = listener } - /** The type of Music the search view is currently filtering to. */ - var searchFilterMode: MusicMode? - get() = - MusicMode.fromIntCode( - inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE)) - set(value) { - inner.edit { - putInt( - context.getString(R.string.set_key_search_filter), - value?.intCode ?: Int.MIN_VALUE) - apply() + override fun unregisterListener(listener: L) { + if (this.listener !== listener) { + logW("Given listener was not the current listener.") } + this.listener = null + // No longer have a listener, detach from the preferences instance. + sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) } - /** The Song [Sort] mode used in the Home UI. */ - var libSongSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode) - apply() - } + final override fun onSharedPreferenceChanged( + sharedPreferences: SharedPreferences, + key: String + ) { + onSettingChanged(key, unlikelyToBeNull(listener)) } - /** The Album [Sort] mode used in the Home UI. */ - var libAlbumSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_albums_sort), value.intCode) - apply() - } - } - - /** The Artist [Sort] mode used in the Home UI. */ - var libArtistSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_artists_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_artists_sort), value.intCode) - apply() - } - } - - /** The Genre [Sort] mode used in the Home UI. */ - var libGenreSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_lib_genres_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_lib_genres_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Album Detail UI. */ - var detailAlbumSort: Sort - get() { - var sort = - Sort.fromIntCode( - inner.getInt( - context.getString(R.string.set_key_detail_album_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDisc, true) - - // Correct legacy album sort modes to Disc - if (sort.mode is Sort.Mode.ByName) { - sort = sort.withMode(Sort.Mode.ByDisc) - } - - return sort - } - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_album_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Artist Detail UI. */ - var detailArtistSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByDate, false) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode) - apply() - } - } - - /** The [Sort] mode used in the Genre Detail UI. */ - var detailGenreSort: Sort - get() = - Sort.fromIntCode( - inner.getInt(context.getString(R.string.set_key_detail_genre_sort), Int.MIN_VALUE)) - ?: Sort(Sort.Mode.ByName, true) - set(value) { - inner.edit { - putInt(context.getString(R.string.set_key_detail_genre_sort), value.intCode) - apply() - } - } - - /** Legacy keys that are no longer used, but still have to be migrated. */ - private object OldKeys { - const val KEY_ACCENT3 = "auxio_accent" - const val KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" - const val KEY_SHOW_COVERS = "KEY_SHOW_COVERS" - const val KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" - const val KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" - const val KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" + /** + * Called when a setting entry with the given [key] has changed. + * @param key The key of the changed setting. + * @param listener The implementation's listener that updates should be applied to. + */ + protected open fun onSettingChanged(key: String, listener: L) {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt deleted file mode 100644 index 03ccaa199..000000000 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * - * 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.settings - -import android.os.Bundle -import android.view.LayoutInflater -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.transition.MaterialFadeThrough -import org.oxycblt.auxio.databinding.FragmentSettingsBinding -import org.oxycblt.auxio.ui.ViewBindingFragment - -/** - * A [Fragment] wrapper containing the preference fragment and a companion Toolbar. - * @author Alexander Capehart (OxygenCobalt) - */ -class SettingsFragment : ViewBindingFragment() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - exitTransition = MaterialFadeThrough() - } - - override fun onCreateBinding(inflater: LayoutInflater) = - FragmentSettingsBinding.inflate(inflater) - - override fun onBindingCreated(binding: FragmentSettingsBinding, savedInstanceState: Bundle?) { - // Point AppBarLayout to the preference fragment's RecyclerView. - binding.settingsAppbar.liftOnScrollTargetViewId = androidx.preference.R.id.recycler_view - binding.settingsToolbar.setNavigationOnClickListener { findNavController().navigateUp() } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt new file mode 100644 index 000000000..52506caf5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/AudioPreferenceFragment.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings.categories + +import androidx.navigation.fragment.findNavController +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.BasePreferenceFragment +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference + +/** + * Audio settings interface. + * @author Alexander Capehart (OxygenCobalt) + */ +class AudioPreferenceFragment : BasePreferenceFragment(R.xml.preferences_audio) { + + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { + if (preference.key == getString(R.string.set_key_pre_amp)) { + findNavController().navigate(AudioPreferenceFragmentDirections.goToPreAmpDialog()) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt new file mode 100644 index 000000000..b38177d1e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings.categories + +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import coil.Coil +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.BasePreferenceFragment +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference + +/** + * "Content" settings. + * @author Alexander Capehart (OxygenCobalt) + */ +class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) { + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { + if (preference.key == getString(R.string.set_key_separators)) { + findNavController().navigate(MusicPreferenceFragmentDirections.goToSeparatorsDialog()) + } + } + + override fun onSetupPreference(preference: Preference) { + if (preference.key == getString(R.string.set_key_cover_mode)) { + preference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, _ -> + Coil.imageLoader(requireContext()).memoryCache?.clear() + true + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt new file mode 100644 index 000000000..73c5147ec --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/PersonalizePreferenceFragment.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings.categories + +import androidx.navigation.fragment.findNavController +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.BasePreferenceFragment +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference + +/** + * Personalization settings interface. + * @author Alexander Capehart (OxygenCobalt) + */ +class PersonalizePreferenceFragment : BasePreferenceFragment(R.xml.preferences_personalize) { + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { + if (preference.key == getString(R.string.set_key_home_tabs)) { + findNavController().navigate(PersonalizePreferenceFragmentDirections.goToTabDialog()) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt new file mode 100644 index 000000000..443daff5b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings.categories + +import androidx.appcompat.app.AppCompatDelegate +import androidx.navigation.fragment.findNavController +import androidx.preference.Preference +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.BasePreferenceFragment +import org.oxycblt.auxio.settings.ui.WrappedDialogPreference +import org.oxycblt.auxio.ui.UISettings +import org.oxycblt.auxio.util.isNight + +class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { + if (preference.key == getString(R.string.set_key_accent)) { + findNavController().navigate(UIPreferenceFragmentDirections.goToAccentDialog()) + } + } + + override fun onSetupPreference(preference: Preference) { + when (preference.key) { + getString(R.string.set_key_theme) -> { + preference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, value -> + AppCompatDelegate.setDefaultNightMode(value as Int) + true + } + } + getString(R.string.set_key_accent) -> { + preference.summary = getString(UISettings.from(requireContext()).accent.name) + } + getString(R.string.set_key_black_theme) -> { + preference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, _ -> + val activity = requireActivity() + if (activity.isNight) { + activity.recreate() + } + + true + } + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt deleted file mode 100644 index 3eebc3176..000000000 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/PreferenceFragment.kt +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * - * 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.settings.prefs - -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.view.updatePadding -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import androidx.preference.Preference -import androidx.preference.PreferenceCategory -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.children -import androidx.recyclerview.widget.RecyclerView -import coil.Coil -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.settings.Settings -import org.oxycblt.auxio.settings.SettingsFragmentDirections -import org.oxycblt.auxio.util.androidActivityViewModels -import org.oxycblt.auxio.util.isNight -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast -import org.oxycblt.auxio.util.systemBarInsetsCompat - -/** - * The [PreferenceFragmentCompat] that displays the list of settings. - * @author Alexander Capehart (OxygenCobalt) - */ -class PreferenceFragment : PreferenceFragmentCompat() { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() - private val musicModel: MusicViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - preferenceManager.onDisplayPreferenceDialogListener = this - preferenceScreen.children.forEach(::setupPreference) - - // Configure the RecyclerView to support edge-to-edge. - view.findViewById(androidx.preference.R.id.recycler_view).apply { - clipToPadding = false - setOnApplyWindowInsetsListener { _, insets -> - updatePadding(bottom = insets.systemBarInsetsCompat.bottom) - insets - } - } - - logD("Fragment created") - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.prefs_main, rootKey) - } - - @Suppress("Deprecation") - override fun onDisplayPreferenceDialog(preference: Preference) { - when (preference) { - is IntListPreference -> { - // Copy the built-in preference dialog launching code into our project so - // we can automatically use the provided preference class. - val dialog = IntListPreferenceDialog.from(preference) - dialog.setTargetFragment(this, 0) - dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) - } - is WrappedDialogPreference -> { - // WrappedDialogPreference cannot launch a dialog on it's own, it has to - // be handled manually. - val directions = - when (preference.key) { - getString(R.string.set_key_accent) -> - SettingsFragmentDirections.goToAccentDialog() - getString(R.string.set_key_lib_tabs) -> - SettingsFragmentDirections.goToTabDialog() - getString(R.string.set_key_pre_amp) -> - SettingsFragmentDirections.goToPreAmpDialog() - getString(R.string.set_key_music_dirs) -> - SettingsFragmentDirections.goToMusicDirsDialog() - getString(R.string.set_key_separators) -> - SettingsFragmentDirections.goToSeparatorsDialog() - else -> error("Unexpected dialog key ${preference.key}") - } - findNavController().navigate(directions) - } - else -> super.onDisplayPreferenceDialog(preference) - } - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - // Hook generic preferences to their specified preferences - // TODO: These seem like good things to put into a side navigation view, if I choose to - // do one. - when (preference.key) { - getString(R.string.set_key_save_state) -> { - playbackModel.savePlaybackState { saved -> - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - if (saved) { - context?.showToast(R.string.lbl_state_saved) - } else { - context?.showToast(R.string.err_did_not_save) - } - } - } - getString(R.string.set_key_wipe_state) -> { - playbackModel.wipePlaybackState { wiped -> - if (wiped) { - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - context?.showToast(R.string.lbl_state_wiped) - } else { - context?.showToast(R.string.err_did_not_wipe) - } - } - } - getString(R.string.set_key_restore_state) -> - playbackModel.tryRestorePlaybackState { restored -> - if (restored) { - // Use the nullable context, as we could try to show a toast when this - // fragment is no longer attached. - context?.showToast(R.string.lbl_state_restored) - } else { - context?.showToast(R.string.err_did_not_restore) - } - } - getString(R.string.set_key_reindex) -> musicModel.refresh() - getString(R.string.set_key_rescan) -> musicModel.rescan() - else -> return super.onPreferenceTreeClick(preference) - } - - return true - } - - private fun setupPreference(preference: Preference) { - val settings = Settings(requireContext()) - - if (!preference.isVisible) { - // Nothing to do. - return - } - - if (preference is PreferenceCategory) { - preference.children.forEach(::setupPreference) - return - } - - when (preference.key) { - getString(R.string.set_key_theme) -> { - preference.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _, value -> - AppCompatDelegate.setDefaultNightMode(value as Int) - true - } - } - getString(R.string.set_key_accent) -> { - preference.summary = getString(settings.accent.name) - } - getString(R.string.set_key_black_theme) -> { - preference.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _, _ -> - val activity = requireActivity() - if (activity.isNight) { - activity.recreate() - } - - true - } - } - getString(R.string.set_key_cover_mode) -> { - preference.onPreferenceChangeListener = - Preference.OnPreferenceChangeListener { _, _ -> - Coil.imageLoader(requireContext()).memoryCache?.clear() - true - } - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreference.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt rename to app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreference.kt index 8afa0ec8d..1289e121e 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreference.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreference.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.settings.prefs +package org.oxycblt.auxio.settings.ui import android.content.Context import android.content.res.TypedArray diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt rename to app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt index 72f8f3383..30deeab85 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/IntListPreferenceDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.settings.prefs +package org.oxycblt.auxio.settings.ui import android.os.Bundle import androidx.preference.PreferenceDialogFragmentCompat diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt new file mode 100644 index 000000000..5ab4b0ebf --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.settings.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceGroupAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.BackportMaterialDividerItemDecoration +import org.oxycblt.auxio.R + +/** + * A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly + * separate preference categories. + * @author Alexander Capehart (OxygenCobalt) + */ +class PreferenceHeaderItemDecoration +@JvmOverloads +constructor( + context: Context, + attributeSet: AttributeSet? = null, + defStyleAttr: Int = R.attr.materialDividerStyle, + orientation: Int = LinearLayoutManager.VERTICAL +) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { + @SuppressLint("RestrictedApi") + override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = + try { + // Add a divider if the next item is a header (in this case a preference category + // that corresponds to a header viewholder). This organizes the divider to separate + // the ends of content rather than the beginning of content, alongside an added benefit + // of preventing top headers from having a divider applied. + (adapter as PreferenceGroupAdapter).getItem(position + 1) is PreferenceCategory + } catch (e: ClassCastException) { + false + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/prefs/WrappedDialogPreference.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/WrappedDialogPreference.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/settings/prefs/WrappedDialogPreference.kt rename to app/src/main/java/org/oxycblt/auxio/settings/ui/WrappedDialogPreference.kt index 429364a94..ec5317b4e 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/prefs/WrappedDialogPreference.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/WrappedDialogPreference.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.settings.prefs +package org.oxycblt.auxio.settings.ui import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt index fdc0c9671..aefcedd1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt @@ -24,7 +24,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.systemGestureInsetsCompat @@ -37,7 +37,7 @@ import org.oxycblt.auxio.util.systemGestureInsetsCompat * @author Alexander Capehart (OxygenCobalt) */ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : - NeoBottomSheetBehavior(context, attributeSet) { + BackportBottomSheetBehavior(context, attributeSet) { private var initalized = false init { @@ -56,7 +56,7 @@ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: /** * Called when window insets are being applied to the [View] this [BaseBottomSheetBehavior] is * linked to. - * @param child The child view recieving the [WindowInsets]. + * @param child The child view receiving the [WindowInsets]. * @param insets The [WindowInsets] to apply. * @return The (possibly modified) [WindowInsets]. * @see View.onApplyWindowInsets diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt index dfd1d2d25..77f1cfc01 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt @@ -22,7 +22,7 @@ import android.util.AttributeSet import android.view.View import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import kotlin.math.abs import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat @@ -42,7 +42,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri private var setup = false override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { - if (dependency.coordinatorLayoutBehavior is NeoBottomSheetBehavior) { + if (dependency.coordinatorLayoutBehavior is BackportBottomSheetBehavior) { dep = dependency return true } @@ -55,7 +55,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child: V, dependency: View ): Boolean { - val behavior = dependency.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dependency.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return false @@ -87,7 +87,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri heightUsed: Int ): Boolean { val dep = dep ?: return false - val behavior = dep.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dep.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return false @@ -106,7 +106,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child.setOnApplyWindowInsetsListener { _, insets -> lastInsets = insets val dep = dep ?: return@setOnApplyWindowInsetsListener insets - val behavior = dep.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dep.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return@setOnApplyWindowInsetsListener insets @@ -138,7 +138,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child.layout(0, 0, child.measuredWidth, child.measuredHeight) } - private fun NeoBottomSheetBehavior<*>.calculateConsumedByBar(): Int { + private fun BackportBottomSheetBehavior<*>.calculateConsumedByBar(): Int { val offset = calculateSlideOffset() if (offset == Float.MIN_VALUE || peekHeight < 0) { return Int.MIN_VALUE diff --git a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt index 2ea31a0c4..becc077ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/NavigationViewModel.kt @@ -79,15 +79,15 @@ class NavigationViewModel : ViewModel() { /** * Navigate to a given [Music] item. Will do nothing if already navigating. - * @param item The [Music] to navigate to. + * @param music The [Music] to navigate to. */ - fun exploreNavigateTo(item: Music) { + fun exploreNavigateTo(music: Music) { if (_exploreNavigationItem.value != null) { logD("Already navigating, not doing explore action") return } - logD("Navigating to ${item.rawName}") - _exploreNavigationItem.value = item + logD("Navigating to ${music.rawName}") + _exploreNavigationItem.value = music } /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt new file mode 100644 index 000000000..5e9de4b83 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.ui + +import android.content.Context +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import org.oxycblt.auxio.R +import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.accent.Accent +import org.oxycblt.auxio.util.logD + +/** + * User configuration for the general app UI. + * @author Alexander Capehart (OxygenCobalt) + */ +interface UISettings : Settings { + /** The current theme. Represented by the AppCompatDelegate constants. */ + val theme: Int + /** Whether to use a black background when a dark theme is currently used. */ + val useBlackTheme: Boolean + /** The current [Accent] (Color Scheme). */ + var accent: Accent + /** Whether to round additional UI elements that require album covers to be rounded. */ + val roundMode: Boolean + + interface Listener { + /** Called when [roundMode] changes. */ + fun onRoundModeChanged() + } + + private class Real(context: Context) : Settings.Real(context), UISettings { + override val theme: Int + get() = + sharedPreferences.getInt( + getString(R.string.set_key_theme), AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + override val useBlackTheme: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_black_theme), false) + + override var accent: Accent + get() = + Accent.from( + sharedPreferences.getInt(getString(R.string.set_key_accent), Accent.DEFAULT)) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_accent), value.index) + apply() + } + } + + override val roundMode: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_round_mode), false) + + override fun migrate() { + if (sharedPreferences.contains(OLD_KEY_ACCENT3)) { + logD("Migrating $OLD_KEY_ACCENT3") + + var accent = sharedPreferences.getInt(OLD_KEY_ACCENT3, 5) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Accents were previously frozen as soon as the OS was updated to android + // twelve, as dynamic colors were enabled by default. This is no longer the + // case, so we need to re-update the setting to dynamic colors here. + accent = 16 + } + + sharedPreferences.edit { + putInt(getString(R.string.set_key_accent), accent) + remove(OLD_KEY_ACCENT3) + apply() + } + } + } + + override fun onSettingChanged(key: String, listener: Listener) { + if (key == getString(R.string.set_key_round_mode)) { + listener.onRoundModeChanged() + } + } + + private companion object { + const val OLD_KEY_ACCENT3 = "auxio_accent" + } + } + + companion object { + /** + * Get a framework-backed implementation. + * @param context [Context] required. + */ + fun from(context: Context): UISettings = Real(context) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt index fd362131d..ae53a8a7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt @@ -74,12 +74,11 @@ abstract class ViewBindingDialogFragment : DialogFragment() { * @return The currently-inflated [ViewBinding]. * @throws IllegalStateException if the [ViewBinding] is not inflated. */ - protected fun requireBinding(): VB { - return requireNotNull(_binding) { + protected fun requireBinding() = + requireNotNull(_binding) { "ViewBinding was available. Fragment should be a valid state " + "right now, but instead it was ${lifecycle.currentState}" } - } final override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt index b5ece20e2..aaaf3119e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingFragment.kt @@ -65,12 +65,11 @@ abstract class ViewBindingFragment : Fragment() { * @return The currently-inflated [ViewBinding]. * @throws IllegalStateException if the [ViewBinding] is not inflated. */ - protected fun requireBinding(): VB { - return requireNotNull(_binding) { + protected fun requireBinding() = + requireNotNull(_binding) { "ViewBinding was available. Fragment should be a valid state " + "right now, but instead it was ${lifecycle.currentState}" } - } final override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt index 1be63d5df..cfa6525bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.ui.accent import android.os.Build import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.util.logW private val ACCENT_NAMES = @@ -112,7 +111,7 @@ private val ACCENT_PRIMARY_COLORS = * @param index The unique number for this particular accent. * @author Alexander Capehart (OxygenCobalt) */ -class Accent private constructor(val index: Int) : Item { +class Accent private constructor(val index: Int) { /** The name of this [Accent]. */ val name: Int get() = ACCENT_NAMES[index] diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt index a4c1e6015..46e7e66fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentAdapter.kt @@ -33,7 +33,7 @@ import org.oxycblt.auxio.util.inflater * @param listener A [ClickableListListener] to bind interactions to. * @author Alexander Capehart (OxygenCobalt) */ -class AccentAdapter(private val listener: ClickableListListener) : +class AccentAdapter(private val listener: ClickableListListener) : RecyclerView.Adapter() { /** The currently selected [Accent]. */ var selectedAccent: Accent? = null @@ -93,7 +93,7 @@ class AccentViewHolder private constructor(private val binding: ItemAccentBindin * @param accent The new [Accent] to bind. * @param listener A [ClickableListListener] to bind interactions to. */ - fun bind(accent: Accent, listener: ClickableListListener) { + fun bind(accent: Accent, listener: ClickableListListener) { listener.bind(accent, this, binding.accent) binding.accent.apply { // Add a Tooltip based on the content description so that the purpose of this diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index 2cb3c93d4..c1ec29ad2 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -25,8 +25,7 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogAccentBinding import org.oxycblt.auxio.list.ClickableListListener -import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull @@ -36,7 +35,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ class AccentCustomizeDialog : - ViewBindingDialogFragment(), ClickableListListener { + ViewBindingDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) @@ -45,7 +44,7 @@ class AccentCustomizeDialog : builder .setTitle(R.string.set_accent) .setPositiveButton(R.string.lbl_ok) { _, _ -> - val settings = Settings(requireContext()) + val settings = UISettings.from(requireContext()) if (accentAdapter.selectedAccent == settings.accent) { // Nothing to do. return@setPositiveButton @@ -66,7 +65,7 @@ class AccentCustomizeDialog : if (savedInstanceState != null) { Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT)) } else { - Settings(requireContext()).accent + UISettings.from(requireContext()).accent }) } @@ -80,8 +79,7 @@ class AccentCustomizeDialog : binding.accentRecycler.adapter = null } - override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) { - check(item is Accent) { "Unexpected datatype: ${item::class.java}" } + override fun onClick(item: Accent, viewHolder: RecyclerView.ViewHolder) { accentAdapter.setSelectedAccent(item) } diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index dd386e770..6441f94ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -33,6 +33,17 @@ fun unlikelyToBeNull(value: T?) = value!! } +/** + * Require that the given data is a specific type [T]. + * @param data The data to check. + * @return A data casted to [T]. + * @throws IllegalStateException If the data cannot be casted to [T]. + */ +inline fun requireIs(data: Any?): T { + check(data is T) { "Unexpected datatype: ${data?.let { it::class.simpleName }}" } + return data +} + /** * Aliases a check to ensure that the given number is non-zero. * @return The given number if it's non-zero, null otherwise. diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 638299bb9..180165d97 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -18,20 +18,21 @@ package org.oxycblt.auxio.widgets import android.content.Context -import android.content.SharedPreferences import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.settings.Settings +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.logD @@ -42,20 +43,22 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class WidgetComponent(private val context: Context) : - PlaybackStateManager.Listener, SharedPreferences.OnSharedPreferenceChangeListener { + PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { private val playbackManager = PlaybackStateManager.getInstance() - private val settings = Settings(context) + private val uiSettings = UISettings.from(context) + private val imageSettings = ImageSettings.from(context) private val widgetProvider = WidgetProvider() private val provider = BitmapProvider(context) init { playbackManager.addListener(this) - settings.addListener(this) + uiSettings.registerListener(this) + imageSettings.registerListener(this) } /** Update [WidgetProvider] with the current playback state. */ fun update() { - val song = playbackManager.song + val song = playbackManager.queue.currentSong if (song == null) { logD("No song, resetting widget") widgetProvider.update(context, null) @@ -65,7 +68,7 @@ class WidgetComponent(private val context: Context) : // Note: Store these values here so they remain consistent once the bitmap is loaded. val isPlaying = playbackManager.playerState.isPlaying val repeatMode = playbackManager.repeatMode - val isShuffled = playbackManager.isShuffled + val isShuffled = playbackManager.queue.isShuffled provider.load( song, @@ -75,7 +78,7 @@ class WidgetComponent(private val context: Context) : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12, always round the cover with the widget's inner radius context.getDimenPixels(android.R.dimen.system_app_widget_inner_radius) - } else if (settings.roundMode) { + } else if (uiSettings.roundMode) { // < Android 12, but the user still enabled round mode. context.getDimenPixels(R.dimen.size_corners_medium) } else { @@ -106,27 +109,23 @@ class WidgetComponent(private val context: Context) : /** Release this instance, preventing any further events from updating the widget instances. */ fun release() { provider.release() - settings.removeListener(this) + uiSettings.unregisterListener(this) widgetProvider.reset(context) playbackManager.removeListener(this) } // --- CALLBACKS --- - // Hook all the major song-changing updates + the major player state updates - // to updating the "Now Playing" widget. - override fun onIndexMoved(index: Int) = update() - override fun onNewPlayback(index: Int, queue: List, parent: MusicParent?) = update() + // Respond to all major song or player changes that will affect the widget + override fun onIndexMoved(queue: Queue) = update() + override fun onQueueReordered(queue: Queue) = update() + override fun onNewPlayback(queue: Queue, parent: MusicParent?) = update() override fun onStateChanged(state: InternalPlayer.State) = update() - override fun onShuffledChanged(isShuffled: Boolean) = update() override fun onRepeatChanged(repeatMode: RepeatMode) = update() - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - if (key == context.getString(R.string.set_key_cover_mode) || - key == context.getString(R.string.set_key_round_mode)) { - update() - } - } + // Respond to settings changes that will affect the widget + override fun onRoundModeChanged() = update() + override fun onCoverModeChanged() = update() /** * A condensed form of the playback state that is safe to use in AppWidgets. diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 4c5259d9a..a333ec2f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -31,7 +31,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.system.PlaybackService -import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.* /** @@ -197,7 +196,7 @@ class WidgetProvider : AppWidgetProvider() { // Below API 31, enable a rounded bar only if round mode is enabled. // On API 31+, the bar should always be round in order to fit in with other widgets. val background = - if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (useRoundedRemoteViews(context)) { R.drawable.ui_widget_bar_round } else { R.drawable.ui_widget_bar_system @@ -216,7 +215,7 @@ class WidgetProvider : AppWidgetProvider() { // On API 31+, the background should always be round in order to fit in with other // widgets. val background = - if (Settings(context).roundMode && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (useRoundedRemoteViews(context)) { R.drawable.ui_widget_bg_round } else { R.drawable.ui_widget_bg_system diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 58f75b0a0..2fe319470 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -27,6 +27,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.LayoutRes import kotlin.math.sqrt +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isLandscape import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -132,3 +133,12 @@ fun AppWidgetManager.updateAppWidgetCompat( } } } + +/** + * Returns whether rounded UI elements are appropriate for the widget, either based on the current + * settings or if the widget has to fit in aesthetically with other widgets. + * @param context [Context] configuration to use. + * @return true if to use round mode, false otherwise. + */ +fun useRoundedRemoteViews(context: Context) = + UISettings.from(context).roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/res/drawable/ic_accent_24.xml b/app/src/main/res/drawable/ic_accent_24.xml index 40905e6e4..e4d886ec1 100644 --- a/app/src/main/res/drawable/ic_accent_24.xml +++ b/app/src/main/res/drawable/ic_accent_24.xml @@ -2,7 +2,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_24.xml b/app/src/main/res/drawable/ic_add_24.xml new file mode 100644 index 000000000..c056f550e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_equalizer.xml b/app/src/main/res/drawable/ic_config_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_equalizer.xml rename to app/src/main/res/drawable/ic_config_24.xml diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index a8eb3e006..df19b3a25 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -4,13 +4,93 @@ xmlns:tools="http://schemas.android.com/tools" style="@style/Widget.Auxio.Dialog.NestedScrollView" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" android:orientation="vertical"> - + android:layout_height="match_parent"> + + + + + + + + + + + + + + + + + + +