commit
240b4d6b2a
196 changed files with 6389 additions and 3270 deletions
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
|
@ -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
|
||||
|
|
30
CHANGELOG.md
30
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
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.1">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.1&color=0D5AF5">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.2">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.2&color=0D5AF5">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
||||
|
|
|
@ -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 {
|
||||
|
|
39
app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
Normal file
39
app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -21,7 +21,7 @@
|
|||
<queries />
|
||||
|
||||
<application
|
||||
android:name=".AuxioApp"
|
||||
android:name=".Auxio"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
|
|
@ -83,9 +83,10 @@ import java.util.Map;
|
|||
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
|
||||
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
|
||||
*
|
||||
* Modified at several points by Alexander Capehart to work around miscellaneous issues.
|
||||
* Modified at several points by Alexander Capehart to backport miscellaneous fixes not currently
|
||||
* obtainable in the currently used MDC library.
|
||||
*/
|
||||
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
|
||||
public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
|
||||
|
||||
/** Listener for monitoring events about bottom sheets. */
|
||||
public abstract static class BottomSheetCallback {
|
||||
|
@ -318,9 +319,9 @@ public class NeoBottomSheetBehavior<V extends View> 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<V extends View> 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<V extends View> 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<V extends View> 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 <V extends View> NeoBottomSheetBehavior<V> from(@NonNull V view) {
|
||||
public static <V extends View> BackportBottomSheetBehavior<V> 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<V>) behavior;
|
||||
return (BackportBottomSheetBehavior<V>) behavior;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2200,3 +2201,4 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <pre>
|
||||
* dividerItemDecoration = new MaterialDividerItemDecoration(recyclerView.getContext(),
|
||||
* layoutManager.getOrientation());
|
||||
* recyclerView.addItemDecoration(dividerItemDecoration);
|
||||
* </pre>
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
|
||||
class AlbumDetailFragment :
|
||||
ListFragment<Song, FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), AlbumDetailAd
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateList(items: List<Item>) {
|
||||
detailAdapter.submitList(items, BasicListInstructions.DIFF)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
detailAdapter.setSelected(selected.toSet())
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentDetailBinding>(), DetailAdapter.Listener {
|
||||
class ArtistDetailFragment :
|
||||
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||
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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), DetailAdapte
|
|||
else -> null
|
||||
}
|
||||
|
||||
detailAdapter.setPlayingItem(playingItem, isPlaying)
|
||||
detailAdapter.setPlaying(playingItem, isPlaying)
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
|
@ -237,8 +235,12 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateList(items: List<Item>) {
|
||||
detailAdapter.submitList(items, BasicListInstructions.DIFF)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
detailAdapter.setSelected(selected.toSet())
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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<List<Item>> = _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<List<Item>> = _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.
|
||||
*/
|
||||
|
|
|
@ -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<FragmentDetailBinding>(), DetailAdapter.Listener {
|
||||
class GenreDetailFragment :
|
||||
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||
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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), 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<FragmentDetailBinding>(), DetailAdapter
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateList(items: List<Item>) {
|
||||
detailAdapter.submitList(items, BasicListInstructions.DIFF)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
detailAdapter.setSelected(selected.toSet())
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Song> {
|
||||
/**
|
||||
* 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<Item>() {
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
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<Album>() {
|
||||
object : SimpleDiffCallback<Album>() {
|
||||
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<DiscHeader>() {
|
||||
object : SimpleDiffCallback<DiscHeader>() {
|
||||
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<Song>) {
|
||||
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<Song>() {
|
||||
object : SimpleDiffCallback<Song>() {
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
|
||||
}
|
||||
|
|
|
@ -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<Music>) :
|
||||
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<Item>() {
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
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<Artist>() {
|
||||
object : SimpleDiffCallback<Artist>() {
|
||||
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<Album>) {
|
||||
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<Album>() {
|
||||
object : SimpleDiffCallback<Album>() {
|
||||
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<Song>) {
|
||||
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<Song>() {
|
||||
object : SimpleDiffCallback<Song>() {
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.album.rawName == newItem.album.rawName
|
||||
|
|
|
@ -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<Item>
|
||||
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), 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<Item>
|
||||
) :
|
||||
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
|
||||
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<Item>
|
||||
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<Item>) {
|
||||
differ.submitList(newList)
|
||||
}
|
||||
|
||||
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
|
||||
interface Listener : SelectableListListener {
|
||||
interface Listener<in T : Music> : SelectableListListener<T> {
|
||||
// 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<Item>() {
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
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<SortHeader>() {
|
||||
object : SimpleDiffCallback<SortHeader>() {
|
||||
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
|
|
|
@ -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<Music>) :
|
||||
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<Item>() {
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
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<Genre>() {
|
||||
object : SimpleDiffCallback<Genre>() {
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.songs.size == newItem.songs.size &&
|
||||
|
|
|
@ -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<MusicStore.Library>
|
||||
) {
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
||||
if (result.isSuccess) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
|
|
78
app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
Normal file
78
app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<HomeSettings.Listener> {
|
||||
/** The tabs to show in the home UI. */
|
||||
var homeTabs: Array<Tab>
|
||||
/** 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<Listener>(context), HomeSettings {
|
||||
override var homeTabs: Array<Tab>
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val songLists: StateFlow<List<Song>>
|
||||
val songsList: StateFlow<List<Song>>
|
||||
get() = _songsList
|
||||
|
||||
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
||||
|
@ -70,11 +64,15 @@ class HomeViewModel(application: Application) :
|
|||
val genresList: StateFlow<List<Genre>>
|
||||
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<MusicMode> = 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<Tab.Visible>().map { it.mode }
|
||||
private fun makeTabModes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>(),
|
||||
ListFragment<Album, FragmentHomeListBinding>(),
|
||||
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<Album>) {
|
||||
albumAdapter.submitList(albums, BasicListInstructions.REPLACE)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
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<AlbumViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
|
||||
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>(
|
||||
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<Album>) {
|
||||
differ.replaceList(newList)
|
||||
holder.bind(getItem(position), listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>(),
|
||||
ListFragment<Artist, FragmentHomeListBinding>(),
|
||||
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<Artist>) {
|
||||
artistAdapter.submitList(artists, BasicListInstructions.REPLACE)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
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<ArtistViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
|
||||
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>(
|
||||
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<Artist>) {
|
||||
differ.replaceList(newList)
|
||||
holder.bind(getItem(position), listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>(),
|
||||
ListFragment<Genre, FragmentHomeListBinding>(),
|
||||
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<Genre>) {
|
||||
genreAdapter.submitList(artists, BasicListInstructions.REPLACE)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
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<GenreViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
|
||||
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>(
|
||||
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<Genre>) {
|
||||
differ.replaceList(newList)
|
||||
holder.bind(getItem(position), listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeListBinding>(),
|
||||
ListFragment<Song, FragmentHomeListBinding>(),
|
||||
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<Song>) {
|
||||
songAdapter.submitList(songs, BasicListInstructions.REPLACE)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
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<SongViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
private class SongAdapter(private val listener: SelectableListListener<Song>) :
|
||||
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>(
|
||||
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<Song>) {
|
||||
differ.replaceList(newList)
|
||||
holder.bind(getItem(position), listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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<Tab>) :
|
||||
RecyclerView.Adapter<TabViewHolder>() {
|
||||
/** The current array of [Tab]s. */
|
||||
var tabs = arrayOf<Tab>()
|
||||
|
@ -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<Tab>) {
|
||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||
binding.tabCheckBox.apply {
|
||||
// Update the CheckBox name to align with the mode
|
||||
|
|
|
@ -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<DialogTabsBinding>(), EditableListListener {
|
||||
class TabCustomizeDialog :
|
||||
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
|
||||
private val tabAdapter = TabAdapter(this)
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
|
||||
|
@ -46,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), 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<DialogTabsBinding>(), 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 }
|
||||
|
|
87
app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt
Normal file
87
app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<ImageSettings.Listener> {
|
||||
/** 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<Listener>(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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -37,7 +37,8 @@ import org.oxycblt.auxio.util.showToast
|
|||
* A Fragment containing a selectable list.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), SelectableListListener {
|
||||
abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||
SelectionFragment<VB>(), SelectableListListener<T> {
|
||||
protected val navModel: NavigationViewModel by activityViewModels()
|
||||
private var currentMenu: PopupMenu? = null
|
||||
|
||||
|
@ -50,12 +51,11 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), 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<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSelect(item: Item) {
|
||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
override fun onSelect(item: T) {
|
||||
selectionModel.select(item)
|
||||
}
|
||||
|
||||
|
|
|
@ -25,26 +25,22 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
* A basic listener for list interactions.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface ClickableListListener {
|
||||
interface ClickableListListener<in T> {
|
||||
/**
|
||||
* 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<in T> : ClickableListListener<T> {
|
||||
/**
|
||||
* 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<in T> : ClickableListListener<T> {
|
||||
/**
|
||||
* 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
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<T, I, VH : RecyclerView.ViewHolder>(
|
||||
differFactory: ListDiffer.Factory<T, I>
|
||||
) : RecyclerView.Adapter<VH>() {
|
||||
private val differ = differFactory.new(@Suppress("LeakingThis") this)
|
||||
|
||||
final override fun getItemCount() = differ.currentList.size
|
||||
|
||||
/** The current list of [T] items. */
|
||||
val currentList: List<T>
|
||||
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<T>, instructions: I, onDone: () -> Unit = {}) {
|
||||
differ.submitList(newList, instructions, onDone)
|
||||
}
|
||||
}
|
226
app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt
Normal file
226
app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<T, I> {
|
||||
/** The current list of [T] items. */
|
||||
val currentList: List<T>
|
||||
|
||||
/**
|
||||
* 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<T>, 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<T, I> {
|
||||
/**
|
||||
* 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<T, I>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
|
||||
Factory<T, BasicListInstructions>() {
|
||||
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
|
||||
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<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
|
||||
Factory<T, BasicListInstructions>() {
|
||||
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
|
||||
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<T> : ListDiffer<T, BasicListInstructions> {
|
||||
override fun submitList(
|
||||
newList: List<T>,
|
||||
instructions: BasicListInstructions,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
when (instructions) {
|
||||
BasicListInstructions.DIFF -> diffList(newList, onDone)
|
||||
BasicListInstructions.REPLACE -> replaceList(newList, onDone)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun diffList(newList: List<T>, onDone: () -> Unit)
|
||||
protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
|
||||
}
|
||||
|
||||
private class RealAsyncListDiffer<T>(
|
||||
updateCallback: ListUpdateCallback,
|
||||
diffCallback: DiffUtil.ItemCallback<T>
|
||||
) : BasicListDiffer<T>() {
|
||||
private val inner =
|
||||
AsyncListDiffer(updateCallback, AsyncDifferConfig.Builder(diffCallback).build())
|
||||
|
||||
override val currentList: List<T>
|
||||
get() = inner.currentList
|
||||
|
||||
override fun diffList(newList: List<T>, onDone: () -> Unit) {
|
||||
inner.submitList(newList, onDone)
|
||||
}
|
||||
|
||||
override fun replaceList(newList: List<T>, onDone: () -> Unit) {
|
||||
inner.submitList(null) { inner.submitList(newList, onDone) }
|
||||
}
|
||||
}
|
||||
|
||||
private class RealBlockingListDiffer<T>(
|
||||
private val updateCallback: ListUpdateCallback,
|
||||
private val diffCallback: DiffUtil.ItemCallback<T>
|
||||
) : BasicListDiffer<T>() {
|
||||
override var currentList = listOf<T>()
|
||||
|
||||
override fun diffList(newList: List<T>, 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<T>, onDone: () -> Unit) {
|
||||
if (currentList != newList) {
|
||||
diffList(listOf()) { diffList(newList, onDone) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,32 +15,27 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
|
||||
differFactory: ListDiffer.Factory<T, I>
|
||||
) : DiffAdapter<T, I, VH>(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<Item>
|
||||
|
||||
override fun getItemCount() = currentList.size
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||
// Only try to update the playing indicator if the ViewHolder supports it
|
||||
if (holder is ViewHolder) {
|
||||
|
@ -55,10 +50,10 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : 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
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<VH : RecyclerView.ViewHolder> :
|
||||
PlayingIndicatorAdapter<VH>() {
|
||||
private var selectedItems = setOf<Music>()
|
||||
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
|
||||
differFactory: ListDiffer.Factory<T, I>
|
||||
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) {
|
||||
private var selectedItems = setOf<T>()
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
@ -39,9 +41,9 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
|||
|
||||
/**
|
||||
* 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<Music>) {
|
||||
fun setSelected(items: Set<T>) {
|
||||
val oldSelectedItems = selectedItems
|
||||
val newSelectedItems = items.toSet()
|
||||
if (newSelectedItems == oldSelectedItems) {
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
abstract class SimpleDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem
|
||||
}
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<T>(
|
||||
adapter: RecyclerView.Adapter<*>,
|
||||
private val diffCallback: DiffUtil.ItemCallback<T>
|
||||
) {
|
||||
private val updateCallback = AdapterListUpdateCallback(adapter)
|
||||
|
||||
var currentList: List<T> = 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<T>) {
|
||||
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<T>) {
|
||||
if (newList == currentList) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
currentList = emptyList()
|
||||
currentList = newList
|
||||
}
|
||||
}
|
|
@ -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<Song>) {
|
||||
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<Song>() {
|
||||
object : SimpleDiffCallback<Song>() {
|
||||
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<Album>) {
|
||||
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<Album>() {
|
||||
object : SimpleDiffCallback<Album>() {
|
||||
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<Artist>) {
|
||||
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<Artist>() {
|
||||
object : SimpleDiffCallback<Artist>() {
|
||||
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<Genre>) {
|
||||
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<Genre>() {
|
||||
object : SimpleDiffCallback<Genre>() {
|
||||
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<Header>() {
|
||||
object : SimpleDiffCallback<Header>() {
|
||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
|
|
|
@ -71,6 +71,14 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<String> = listOf(),
|
||||
/** @see Album.Raw.releaseType */
|
||||
var releaseTypes: List<String> = listOf(),
|
||||
/** @see Artist.Raw.musicBrainzId */
|
||||
var artistMusicBrainzIds: List<String> = listOf(),
|
||||
/** @see Artist.Raw.name */
|
||||
|
@ -628,10 +632,10 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : 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<Song>) : 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<String>): 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<String>.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<Song>) : 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<Artist.Raw>
|
||||
) {
|
||||
|
@ -955,16 +764,15 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : 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<Music>) : 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<Song>) :
|
|||
* @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<String?>) {
|
||||
@VisibleForTesting
|
||||
fun MessageDigest.update(strings: List<String?>) {
|
||||
strings.forEach(::update)
|
||||
}
|
||||
|
||||
|
@ -1260,7 +1069,8 @@ private fun MessageDigest.update(strings: List<String?>) {
|
|||
* 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 {
|
||||
|
|
224
app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
Normal file
224
app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<MusicSettings.Listener> {
|
||||
/** 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<Listener>(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)
|
||||
}
|
||||
}
|
|
@ -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<Song>,
|
||||
val albums: List<Album>,
|
||||
val artists: List<Artist>,
|
||||
val genres: List<Genre>,
|
||||
) {
|
||||
private val uidMap = HashMap<Music.UID, Music>()
|
||||
|
||||
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 <T : Music> 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>(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>(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>(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>(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 {
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<String>()
|
||||
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
|
||||
|
|
|
@ -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<Song.Raw>) = 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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String, List<String>>()
|
||||
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
||||
val id3v2: Map<String, List<String>>
|
||||
|
@ -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)
|
183
app/src/main/java/org/oxycblt/auxio/music/library/Library.kt
Normal file
183
app/src/main/java/org/oxycblt/auxio/music/library/Library.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song.Raw>, 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 <T : Music> 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>(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>(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>(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>(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<Song>): List<Album> {
|
||||
// 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<Song>, albums: List<Album>): List<Artist> {
|
||||
// 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<Artist.Raw, MutableList<Music>>()
|
||||
|
||||
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<Song>): List<Genre> {
|
||||
// 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<Genre.Raw, MutableList<Song>>()
|
||||
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
|
||||
}
|
||||
}
|
|
@ -15,13 +15,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>) {
|
||||
private fun songsInPlace(songs: MutableList<Song>) {
|
||||
songs.sortWith(mode.getSongComparator(isAscending))
|
||||
}
|
||||
|
|
@ -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<String>.parseMultiValue(settings: Settings) =
|
||||
fun List<String>.parseMultiValue(settings: MusicSettings) =
|
||||
if (size == 1) {
|
||||
first().maybeParseBySeparators(settings)
|
||||
} else {
|
||||
|
@ -99,10 +99,9 @@ fun List<String>.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<String> {
|
||||
private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
|
||||
// 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<String>.parseId3GenreNames(settings: Settings) =
|
||||
fun List<String>.parseId3GenreNames(settings: MusicSettings) =
|
||||
if (size == 1) {
|
||||
first().parseId3MultiValueGenre(settings)
|
||||
} else {
|
||||
|
@ -132,9 +131,10 @@ fun List<String>.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)
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<DialogSeparatorsBinding>() {
|
|||
.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<DialogSeparatorsBinding>() {
|
|||
// 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
|
||||
|
|
|
@ -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<Artist>) :
|
||||
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
|
||||
private var artists = listOf<Artist>()
|
||||
|
||||
|
@ -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<Artist>) {
|
||||
listener.bind(artist, this)
|
||||
binding.pickerImage.bind(artist)
|
||||
binding.pickerName.text = artist.resolveName(binding.context)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Artist> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
|
||||
playbackModel.playFromArtist(song, item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Genre>) :
|
||||
RecyclerView.Adapter<GenreChoiceViewHolder>() {
|
||||
private var genres = listOf<Genre>()
|
||||
|
||||
|
@ -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<Genre>) {
|
||||
listener.bind(genre, this)
|
||||
binding.pickerImage.bind(genre)
|
||||
binding.pickerName.text = genre.resolveName(binding.context)
|
||||
|
|
|
@ -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<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Genre> {
|
||||
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<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
|
||||
playbackModel.playFromGenre(song, item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
package org.oxycblt.auxio.music.storage
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Directory>, val shouldInclude: Boolean)
|
||||
// TODO: Unify include + exclude
|
||||
|
||||
/**
|
||||
* A mime type of a file. Only intended for display.
|
|
@ -15,8 +15,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
|
@ -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<MusicStore.Library>? = null
|
||||
@Volatile private var lastResponse: Result<Library>? = 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<Song> {
|
||||
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
|
||||
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<Song>()
|
||||
val rawSongs = mutableListOf<Song.Raw>()
|
||||
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<Song>): List<Album> {
|
||||
// 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<Song>, albums: List<Album>): List<Artist> {
|
||||
// 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<Artist.Raw, MutableList<Music>>()
|
||||
|
||||
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<Song>): List<Genre> {
|
||||
// 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<Genre.Raw, MutableList<Song>>()
|
||||
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<MusicStore.Library>) {
|
||||
private suspend fun emitCompletion(result: Result<Library>) {
|
||||
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<MusicStore.Library>) : State()
|
||||
data class Complete(val result: Result<Library>) : 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 {
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Int>) : Comparable<Date>
|
|||
|
||||
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<Int>) : Comparable<Date>
|
|||
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<Int>) : Comparable<Date>
|
|||
*/
|
||||
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<Int>) : Comparable<Date>
|
|||
*/
|
||||
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<Int>) : Comparable<Date>
|
|||
*/
|
||||
private fun fromTokens(tokens: List<Int>): Date? {
|
||||
val validated = mutableListOf<Int>()
|
||||
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<Int>) : Comparable<Date>
|
|||
* @param src The input tokens to validate.
|
||||
* @param dst The destination list to add valid tokens to.
|
||||
*/
|
||||
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
|
||||
private fun transformTokens(src: List<Int>, dst: MutableList<Int>) {
|
||||
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)
|
216
app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt
Normal file
216
app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String>): 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<String>.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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<FragmentPlaybackBarBinding>() {
|
|||
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<FragmentPlaybackBarBinding>() {
|
|||
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<FragmentPlaybackBarBinding>() {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
218
app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt
Normal file
218
app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<PlaybackSettings.Listener> {
|
||||
/** 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<Listener>(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)
|
||||
}
|
||||
}
|
|
@ -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<Song?>(null)
|
||||
|
@ -85,6 +83,10 @@ class PlaybackViewModel(application: Application) :
|
|||
val genrePickerSong: StateFlow<Song?>
|
||||
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<Song>, 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<Music>) =
|
||||
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<Music>) =
|
||||
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<Music>): List<Song> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<QueueSongViewHolder>() {
|
||||
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
|
||||
class QueueAdapter(private val listener: EditableListListener<Song>) :
|
||||
DiffAdapter<Song, BasicListInstructions, QueueSongViewHolder>(
|
||||
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<Any>
|
||||
) {
|
||||
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<Song>) {
|
||||
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<Song>) {
|
||||
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<Song>) {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<FragmentQueueBinding>(), EditableListListener {
|
||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
|
||||
private val queueModel: QueueViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val queueAdapter = QueueAdapter(this)
|
||||
|
@ -78,10 +77,11 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), 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<FragmentQueueBinding>(), 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<FragmentQueueBinding>(), 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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<List<Song>> = _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<Int>
|
||||
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<Song>) {
|
||||
// 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<Song>) {
|
||||
// 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<Song>, 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?)
|
||||
}
|
||||
|
|
|
@ -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<DialogPreAmpBinding>() {
|
|||
.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<DialogPreAmpBinding>() {
|
|||
// 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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<MusicParent>(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<Song> {
|
||||
val queue = mutableListOf<Song>()
|
||||
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
|
||||
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
|
||||
private fun readRawQueueState(library: Library): RawQueueState {
|
||||
val heap = mutableListOf<Song?>()
|
||||
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<Song>(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<Int?>()
|
||||
val shuffledMapping = mutableListOf<Int?>()
|
||||
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<Song>?) {
|
||||
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<Song>,
|
||||
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<Song?>,
|
||||
/** @see Queue.SavedState.orderedMapping */
|
||||
val orderedMapping: List<Int>,
|
||||
/** @see Queue.SavedState.shuffledMapping */
|
||||
val shuffledMapping: List<Int>
|
||||
)
|
||||
|
||||
/** 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
|
||||
|
||||
|
|
|
@ -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<Song>()
|
||||
/** 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<Song>, 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<Song>) {
|
||||
_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<Song>) {
|
||||
_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<Song>) {}
|
||||
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<Song>) {}
|
||||
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<Song>, 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 {
|
||||
|
|
393
app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt
Normal file
393
app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>()
|
||||
@Volatile private var orderedMapping = mutableListOf<Int>()
|
||||
@Volatile private var shuffledMapping = mutableListOf<Int>()
|
||||
/** 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<Song>, 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<Song>): 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<Song>): 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<Int?>()
|
||||
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<Int>()
|
||||
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<Song?>,
|
||||
val orderedMapping: List<Int>,
|
||||
val shuffledMapping: List<Int>,
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<Song>) {
|
||||
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<Song>) {
|
||||
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<Song>, 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<Song>) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
||||
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
|
||||
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<Item>, 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<Item>() {
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||
when {
|
||||
oldItem is Song && newItem is Song ->
|
||||
|
|
|
@ -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<FragmentSearchBinding>() {
|
||||
class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||
private val searchModel: SearchViewModel by androidViewModels()
|
||||
private val searchAdapter = SearchAdapter(this)
|
||||
private var imm: InputMethodManager? = null
|
||||
|
@ -134,26 +133,19 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
|||
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<FragmentSearchBinding>() {
|
|||
// 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<FragmentSearchBinding>() {
|
|||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
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.
|
||||
|
|
56
app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt
Normal file
56
app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Nothing> {
|
||||
/** The type of Music the search view is currently filtering to. */
|
||||
var searchFilterMode: MusicMode?
|
||||
|
||||
private class Real(context: Context) : Settings.Real<Nothing>(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)
|
||||
}
|
||||
}
|
|
@ -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<List<Item>>
|
||||
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<Item> {
|
||||
private fun searchImpl(library: Library, query: String): List<Item> {
|
||||
val sort = Sort(Sort.Mode.ByName, true)
|
||||
val filterMode = settings.searchFilterMode
|
||||
val filterMode = searchSettings.searchFilterMode
|
||||
val results = mutableListOf<Item>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
|||
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<FragmentAboutBinding>() {
|
|||
browserIntent.setPackage(null)
|
||||
openAppChooser(browserIntent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No app installed to open the link
|
||||
context.showToast(R.string.err_no_app)
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<AppBarLayout>(R.id.preferences_appbar).liftOnScrollTargetViewId =
|
||||
androidx.preference.R.id.recycler_view
|
||||
view.findViewById<Toolbar>(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)
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<L> {
|
||||
/**
|
||||
* 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<Tab>
|
||||
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<L>(private val context: Context) :
|
||||
Settings<L>, 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) {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<FragmentSettingsBinding>() {
|
||||
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() }
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue