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 }}
|
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
- name: Test app with Gradle
|
||||||
|
run: ./gradlew app:testDebug
|
||||||
- name: Build debug APK with Gradle
|
- name: Build debug APK with Gradle
|
||||||
run: ./gradlew app:packageDebug
|
run: ./gradlew app:packageDebug
|
||||||
- name: Upload debug APK artifact
|
- name: Upload debug APK artifact
|
||||||
|
|
|
||||||
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -1,5 +1,35 @@
|
||||||
# Changelog
|
# 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
|
## 3.0.1
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<h1 align="center"><b>Auxio</b></h1>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.1">
|
<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.1&color=0D5AF5">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.2&color=0D5AF5">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,13 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.0.1"
|
versionName "3.0.2"
|
||||||
versionCode 25
|
versionCode 26
|
||||||
|
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
|
|
||||||
buildFeatures {
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
viewBinding true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
|
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
|
||||||
|
|
@ -36,8 +34,8 @@ android {
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix ".debug"
|
||||||
versionNameSuffix = "-DEBUG"
|
versionNameSuffix "-DEBUG"
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
|
|
@ -47,6 +45,10 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
|
||||||
dependenciesInfo {
|
dependenciesInfo {
|
||||||
includeInApk = false
|
includeInApk = false
|
||||||
includeInBundle = false
|
includeInBundle = false
|
||||||
|
|
@ -110,8 +112,11 @@ dependencies {
|
||||||
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
|
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
|
||||||
implementation "com.google.android.material:material:1.7.0-alpha02"
|
implementation "com.google.android.material:material:1.7.0-alpha02"
|
||||||
|
|
||||||
// LeakCanary
|
// Development
|
||||||
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1"
|
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 {
|
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 />
|
<queries />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".AuxioApp"
|
android:name=".Auxio"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,10 @@ import java.util.Map;
|
||||||
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
|
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
|
||||||
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
|
* 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. */
|
/** Listener for monitoring events about bottom sheets. */
|
||||||
public abstract static class BottomSheetCallback {
|
public abstract static class BottomSheetCallback {
|
||||||
|
|
@ -318,9 +319,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
||||||
|
|
||||||
private int expandHalfwayActionId = View.NO_ID;
|
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);
|
super(context, attrs);
|
||||||
|
|
||||||
peekHeightGestureInsetBuffer =
|
peekHeightGestureInsetBuffer =
|
||||||
|
|
@ -1980,7 +1981,7 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
||||||
skipCollapsed = source.readInt() == 1;
|
skipCollapsed = source.readInt() == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior<?> behavior) {
|
public SavedState(Parcelable superState, @NonNull BackportBottomSheetBehavior<?> behavior) {
|
||||||
super(superState);
|
super(superState);
|
||||||
this.state = behavior.state;
|
this.state = behavior.state;
|
||||||
this.peekHeight = behavior.peekHeight;
|
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
|
* This constructor does not respect flags: {@link BackportBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
|
||||||
* NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link
|
* BackportBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link BackportBottomSheetBehavior#SAVE_HIDEABLE}, {@link
|
||||||
* NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE}
|
* BackportBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link BackportBottomSheetBehavior#SAVE_NONE}
|
||||||
* were set.
|
* were set.
|
||||||
*
|
*
|
||||||
* @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead.
|
* @deprecated Use {@link #SavedState(Parcelable, BackportBottomSheetBehavior)} instead.
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public SavedState(Parcelable superstate, @State int state) {
|
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}.
|
* @param view The {@link View} with {@link BackportBottomSheetBehavior}.
|
||||||
* @return The {@link NeoBottomSheetBehavior} associated with the {@code view}.
|
* @return The {@link BackportBottomSheetBehavior} associated with the {@code view}.
|
||||||
*/
|
*/
|
||||||
@NonNull
|
@NonNull
|
||||||
@SuppressWarnings("unchecked")
|
@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();
|
ViewGroup.LayoutParams params = view.getLayoutParams();
|
||||||
if (!(params instanceof CoordinatorLayout.LayoutParams)) {
|
if (!(params instanceof CoordinatorLayout.LayoutParams)) {
|
||||||
throw new IllegalArgumentException("The view is not a child of CoordinatorLayout");
|
throw new IllegalArgumentException("The view is not a child of CoordinatorLayout");
|
||||||
}
|
}
|
||||||
CoordinatorLayout.Behavior<?> behavior =
|
CoordinatorLayout.Behavior<?> behavior =
|
||||||
((CoordinatorLayout.LayoutParams) params).getBehavior();
|
((CoordinatorLayout.LayoutParams) params).getBehavior();
|
||||||
if (!(behavior instanceof NeoBottomSheetBehavior)) {
|
if (!(behavior instanceof BackportBottomSheetBehavior)) {
|
||||||
throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior");
|
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.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
|
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
|
||||||
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
|
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
|
||||||
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
|
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
|
||||||
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
|
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
|
||||||
import org.oxycblt.auxio.image.extractor.MusicKeyer
|
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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AuxioApp : Application(), ImageLoaderFactory {
|
class Auxio : Application(), ImageLoaderFactory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Migrate any settings that may have changed in an app update.
|
// 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
|
// Adding static shortcuts in a dynamic manner is better than declaring them
|
||||||
// manually, as it will properly handle the difference between debug and release
|
// manually, as it will properly handle the difference between debug and release
|
||||||
// Auxio instances.
|
// Auxio instances.
|
||||||
|
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.music.system.IndexerService
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
import org.oxycblt.auxio.playback.state.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
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.androidViewModels
|
||||||
import org.oxycblt.auxio.util.isNight
|
import org.oxycblt.auxio.util.isNight
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
@ -81,7 +81,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupTheme() {
|
private fun setupTheme() {
|
||||||
val settings = Settings(this)
|
val settings = UISettings.from(this)
|
||||||
// Apply the theme configuration.
|
// Apply the theme configuration.
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
// Apply the color scheme. The black theme requires it's own set of themes since
|
// Apply the color scheme. The black theme requires it's own set of themes since
|
||||||
|
|
@ -131,7 +131,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
val action =
|
val action =
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
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
|
else -> return false
|
||||||
}
|
}
|
||||||
playbackModel.startAction(action)
|
playbackModel.startAction(action)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavDestination
|
import androidx.navigation.NavDestination
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import androidx.navigation.fragment.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.shape.MaterialShapeDrawable
|
||||||
import com.google.android.material.transition.MaterialFadeThrough
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
@ -101,10 +101,10 @@ class MainFragment :
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
|
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
|
||||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED &&
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
|
||||||
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
|
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
|
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
|
||||||
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
|
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -183,7 +183,7 @@ class MainFragment :
|
||||||
// Playback sheet intercepts queue sheet touch events, prevent that from
|
// Playback sheet intercepts queue sheet touch events, prevent that from
|
||||||
// occurring by disabling dragging whenever the queue sheet is expanded.
|
// occurring by disabling dragging whenever the queue sheet is expanded.
|
||||||
playbackSheetBehavior.isDraggable =
|
playbackSheetBehavior.isDraggable =
|
||||||
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED
|
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No queue sheet, fade normally based on the playback sheet
|
// No queue sheet, fade normally based on the playback sheet
|
||||||
|
|
@ -235,8 +235,8 @@ class MainFragment :
|
||||||
tryHideAllSheets()
|
tryHideAllSheets()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since the listener is also reliant on the bottom sheets, we must also update it
|
// Since the navigation listener is also reliant on the bottom sheets, we must also update
|
||||||
// every frame.
|
// it every frame.
|
||||||
callback.invalidateEnabled()
|
callback.invalidateEnabled()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -309,7 +309,7 @@ class MainFragment :
|
||||||
navModel.mainNavigateTo(
|
navModel.mainNavigateTo(
|
||||||
MainNavigationAction.Directions(
|
MainNavigationAction.Directions(
|
||||||
MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
|
MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
|
||||||
playbackModel.finishPlaybackArtistPicker()
|
playbackModel.finishPlaybackGenrePicker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,9 +317,9 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
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.
|
// 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 binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
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.
|
// Make sure the queue is also collapsed here.
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
queueSheetBehavior?.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,17 +340,15 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) {
|
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
|
||||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||||
queueSheetBehavior?.isDraggable = true
|
queueSheetBehavior?.isDraggable = true
|
||||||
|
|
||||||
playbackSheetBehavior.apply {
|
playbackSheetBehavior.apply {
|
||||||
// Make sure the view is draggable, at least until the draw checks kick in.
|
// Make sure the view is draggable, at least until the draw checks kick in.
|
||||||
isDraggable = true
|
isDraggable = true
|
||||||
state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -359,19 +357,19 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
|
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
|
||||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||||
queueSheetBehavior?.apply {
|
queueSheetBehavior?.apply {
|
||||||
isDraggable = false
|
isDraggable = false
|
||||||
state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackSheetBehavior.apply {
|
playbackSheetBehavior.apply {
|
||||||
isDraggable = false
|
isDraggable = false
|
||||||
state = NeoBottomSheetBehavior.STATE_HIDDEN
|
state = BackportBottomSheetBehavior.STATE_HIDDEN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -390,16 +388,21 @@ class MainFragment :
|
||||||
|
|
||||||
// If expanded, collapse the queue sheet first.
|
// If expanded, collapse the queue sheet first.
|
||||||
if (queueSheetBehavior != null &&
|
if (queueSheetBehavior != null &&
|
||||||
queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
|
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
||||||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
|
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||||
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If expanded, collapse the playback sheet next.
|
// If expanded, collapse the playback sheet next.
|
||||||
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
|
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
|
||||||
playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
|
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear out any prior selections.
|
||||||
|
if (selectionModel.consume().isNotEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,8 +428,9 @@ class MainFragment :
|
||||||
val exploreNavController = binding.exploreNavHost.findNavController()
|
val exploreNavController = binding.exploreNavHost.findNavController()
|
||||||
|
|
||||||
isEnabled =
|
isEnabled =
|
||||||
queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
|
||||||
|
selectionModel.selected.value.isNotEmpty() ||
|
||||||
exploreNavController.currentDestination?.id !=
|
exploreNavController.currentDestination?.id !=
|
||||||
exploreNavController.graph.startDestinationId
|
exploreNavController.graph.startDestinationId
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,26 +31,22 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
|
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.util.*
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ListFragment] that shows information about an [Album].
|
* A [ListFragment] that shows information about an [Album].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
|
class AlbumDetailFragment :
|
||||||
|
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
// Information about what album to display is initially within the navigation arguments
|
// 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.
|
// 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.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setAlbumUid(args.albumUid)
|
detailModel.setAlbumUid(args.albumUid)
|
||||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||||
collectImmediately(detailModel.albumList, detailAdapter::submitList)
|
collectImmediately(detailModel.albumList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||||
|
|
@ -126,21 +122,12 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Song) {
|
||||||
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
|
// There can only be one album, so a null mode and an ALBUMS mode will function the same.
|
||||||
when (Settings(requireContext()).detailPlaybackMode) {
|
playbackModel.playFrom(item, detailModel.playbackMode ?: MusicMode.ALBUMS)
|
||||||
// "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 onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Song, anchor: View) {
|
||||||
check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
|
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,12 +141,12 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
|
||||||
|
|
||||||
override fun onOpenSortMenu(anchor: View) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_album_sort) {
|
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(sort.mode.itemId)).isChecked = true
|
||||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||||
setOnMenuItemClickListener { item ->
|
setOnMenuItemClickListener { item ->
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
detailModel.albumSort =
|
detailModel.albumSongSort =
|
||||||
if (item.itemId == R.id.option_sort_asc) {
|
if (item.itemId == R.id.option_sort_asc) {
|
||||||
sort.withAscending(item.isChecked)
|
sort.withAscending(item.isChecked)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -185,10 +172,10 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
|
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
|
||||||
detailAdapter.setPlayingItem(song, isPlaying)
|
detailAdapter.setPlaying(song, isPlaying)
|
||||||
} else {
|
} else {
|
||||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
// 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>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
detailAdapter.setSelectedItems(selected)
|
detailAdapter.setSelected(selected.toSet())
|
||||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
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.detail.recycler.DetailAdapter
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
@ -49,7 +48,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* A [ListFragment] that shows information about an [Artist].
|
* A [ListFragment] that shows information about an [Artist].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
|
class ArtistDetailFragment :
|
||||||
|
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
// Information about what artist to display is initially within the navigation arguments
|
// 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.
|
// 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.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setArtistUid(args.artistUid)
|
detailModel.setArtistUid(args.artistUid)
|
||||||
collectImmediately(detailModel.currentArtist, ::updateItem)
|
collectImmediately(detailModel.currentArtist, ::updateItem)
|
||||||
collectImmediately(detailModel.artistList, detailAdapter::submitList)
|
collectImmediately(detailModel.artistList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||||
|
|
@ -121,27 +121,25 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Music) {
|
||||||
when (music) {
|
when (item) {
|
||||||
|
is Album -> navModel.exploreNavigateTo(item)
|
||||||
is Song -> {
|
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
|
// When configured to play from the selected item, we already have an Artist
|
||||||
// to play from.
|
// to play from.
|
||||||
null ->
|
playbackModel.playFromArtist(
|
||||||
playbackModel.playFromArtist(
|
item, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Album -> navModel.exploreNavigateTo(music)
|
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||||
else -> error("Unexpected datatype: ${music::class.simpleName}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Music, anchor: View) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
|
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
|
||||||
is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_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) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_artist_sort) {
|
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(sort.mode.itemId)).isChecked = true
|
||||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||||
setOnMenuItemClickListener { item ->
|
setOnMenuItemClickListener { item ->
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
|
|
||||||
detailModel.artistSort =
|
detailModel.artistSongSort =
|
||||||
if (item.itemId == R.id.option_sort_asc) {
|
if (item.itemId == R.id.option_sort_asc) {
|
||||||
sort.withAscending(item.isChecked)
|
sort.withAscending(item.isChecked)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -199,7 +197,7 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
detailAdapter.setPlayingItem(playingItem, isPlaying)
|
detailAdapter.setPlaying(playingItem, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
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>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
detailAdapter.setSelectedItems(selected)
|
detailAdapter.setSelected(selected.toSet())
|
||||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,19 @@ package org.oxycblt.auxio.detail
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* A header variation that displays a button to open a sort menu.
|
||||||
* @param titleRes The string resource to use as the header title
|
* @param titleRes The string resource to use as the header title
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class SortHeader(@StringRes val titleRes: Int) : Item
|
data class SortHeader(@StringRes val titleRes: Int) : Item
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header variation that delimits between disc groups.
|
* A header variation that delimits between disc groups.
|
||||||
* @param disc The disc number to be displayed on the header.
|
* @param disc The disc number to be displayed on the header.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class DiscHeader(val disc: Int) : Item
|
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 bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
||||||
* @param sampleRateHz The sample rate, in hertz.
|
* @param sampleRateHz The sample rate, in hertz.
|
||||||
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
|
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class SongProperties(
|
data class SongProperties(
|
||||||
val bitrateKbps: Int?,
|
val bitrateKbps: Int?,
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,13 @@ import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
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.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.music.filesystem.MimeType
|
import org.oxycblt.auxio.music.storage.MimeType
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.music.tags.ReleaseType
|
||||||
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -53,7 +51,8 @@ import org.oxycblt.auxio.util.*
|
||||||
class DetailViewModel(application: Application) :
|
class DetailViewModel(application: Application) :
|
||||||
AndroidViewModel(application), MusicStore.Listener {
|
AndroidViewModel(application), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
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
|
private var currentSongJob: Job? = null
|
||||||
|
|
||||||
|
|
@ -81,10 +80,10 @@ class DetailViewModel(application: Application) :
|
||||||
get() = _albumList
|
get() = _albumList
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [albumList]. */
|
/** The current [Sort] used for [Song]s in [albumList]. */
|
||||||
var albumSort: Sort
|
var albumSongSort: Sort
|
||||||
get() = settings.detailAlbumSort
|
get() = musicSettings.albumSongSort
|
||||||
set(value) {
|
set(value) {
|
||||||
settings.detailAlbumSort = value
|
musicSettings.albumSongSort = value
|
||||||
// Refresh the album list to reflect the new sort.
|
// Refresh the album list to reflect the new sort.
|
||||||
currentAlbum.value?.let(::refreshAlbumList)
|
currentAlbum.value?.let(::refreshAlbumList)
|
||||||
}
|
}
|
||||||
|
|
@ -101,10 +100,10 @@ class DetailViewModel(application: Application) :
|
||||||
val artistList: StateFlow<List<Item>> = _artistList
|
val artistList: StateFlow<List<Item>> = _artistList
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [artistList]. */
|
/** The current [Sort] used for [Song]s in [artistList]. */
|
||||||
var artistSort: Sort
|
var artistSongSort: Sort
|
||||||
get() = settings.detailArtistSort
|
get() = musicSettings.artistSongSort
|
||||||
set(value) {
|
set(value) {
|
||||||
settings.detailArtistSort = value
|
musicSettings.artistSongSort = value
|
||||||
// Refresh the artist list to reflect the new sort.
|
// Refresh the artist list to reflect the new sort.
|
||||||
currentArtist.value?.let(::refreshArtistList)
|
currentArtist.value?.let(::refreshArtistList)
|
||||||
}
|
}
|
||||||
|
|
@ -121,14 +120,21 @@ class DetailViewModel(application: Application) :
|
||||||
val genreList: StateFlow<List<Item>> = _genreList
|
val genreList: StateFlow<List<Item>> = _genreList
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [genreList]. */
|
/** The current [Sort] used for [Song]s in [genreList]. */
|
||||||
var genreSort: Sort
|
var genreSongSort: Sort
|
||||||
get() = settings.detailGenreSort
|
get() = musicSettings.genreSongSort
|
||||||
set(value) {
|
set(value) {
|
||||||
settings.detailGenreSort = value
|
musicSettings.genreSongSort = value
|
||||||
// Refresh the genre list to reflect the new sort.
|
// Refresh the genre list to reflect the new sort.
|
||||||
currentGenre.value?.let(::refreshGenreList)
|
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 {
|
init {
|
||||||
musicStore.addListener(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +143,7 @@ class DetailViewModel(application: Application) :
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library == null) {
|
if (library == null) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
|
|
@ -173,8 +179,8 @@ class DetailViewModel(application: Application) :
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
|
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
|
||||||
* and [songProperties] will be updated to align with the new [Song].
|
* [songProperties] will be updated to align with the new [Song].
|
||||||
* @param uid The UID of the [Song] to load. Must be valid.
|
* @param uid The UID of the [Song] to load. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setSongUid(uid: Music.UID) {
|
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
|
// 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.
|
// 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.
|
// Songs without disc tags become part of Disc 1.
|
||||||
val byDisc = songs.groupBy { it.disc ?: 1 }
|
val byDisc = songs.groupBy { it.disc ?: 1 }
|
||||||
if (byDisc.size > 1) {
|
if (byDisc.size > 1) {
|
||||||
|
|
@ -339,21 +345,21 @@ class DetailViewModel(application: Application) :
|
||||||
|
|
||||||
val byReleaseGroup =
|
val byReleaseGroup =
|
||||||
albums.groupBy {
|
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
|
// "AlbumGrouping" enum that will automatically group and sort
|
||||||
// the artist's albums.
|
// the artist's albums.
|
||||||
when (it.type.refinement) {
|
when (it.releaseType.refinement) {
|
||||||
Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE
|
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
|
||||||
Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES
|
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
|
||||||
null ->
|
null ->
|
||||||
when (it.type) {
|
when (it.releaseType) {
|
||||||
is Album.Type.Album -> AlbumGrouping.ALBUMS
|
is ReleaseType.Album -> AlbumGrouping.ALBUMS
|
||||||
is Album.Type.EP -> AlbumGrouping.EPS
|
is ReleaseType.EP -> AlbumGrouping.EPS
|
||||||
is Album.Type.Single -> AlbumGrouping.SINGLES
|
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
||||||
is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS
|
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
||||||
is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||||
is Album.Type.Mix -> AlbumGrouping.MIXES
|
is ReleaseType.Mix -> AlbumGrouping.MIXES
|
||||||
is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES
|
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +375,7 @@ class DetailViewModel(application: Application) :
|
||||||
if (artist.songs.isNotEmpty()) {
|
if (artist.songs.isNotEmpty()) {
|
||||||
logD("Songs present in this artist, adding header")
|
logD("Songs present in this artist, adding header")
|
||||||
data.add(SortHeader(R.string.lbl_songs))
|
data.add(SortHeader(R.string.lbl_songs))
|
||||||
data.addAll(artistSort.songs(artist.songs))
|
data.addAll(artistSongSort.songs(artist.songs))
|
||||||
}
|
}
|
||||||
|
|
||||||
_artistList.value = data.toList()
|
_artistList.value = data.toList()
|
||||||
|
|
@ -382,12 +388,12 @@ class DetailViewModel(application: Application) :
|
||||||
data.add(Header(R.string.lbl_artists))
|
data.add(Header(R.string.lbl_artists))
|
||||||
data.addAll(genre.artists)
|
data.addAll(genre.artists)
|
||||||
data.add(SortHeader(R.string.lbl_songs))
|
data.add(SortHeader(R.string.lbl_songs))
|
||||||
data.addAll(genreSort.songs(genre.songs))
|
data.addAll(genreSongSort.songs(genre.songs))
|
||||||
_genreList.value = data
|
_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
|
* @param headerTitleRes The title string resource to use for a header created out of an
|
||||||
* instance of this enum.
|
* 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.detail.recycler.GenreDetailAdapter
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
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].
|
* A [ListFragment] that shows information for a particular [Genre].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
|
class GenreDetailFragment :
|
||||||
|
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
// Information about what genre to display is initially within the navigation arguments
|
// 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.
|
// 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.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setGenreUid(args.genreUid)
|
detailModel.setGenreUid(args.genreUid)
|
||||||
collectImmediately(detailModel.currentGenre, ::updateItem)
|
collectImmediately(detailModel.currentGenre, ::updateItem)
|
||||||
collectImmediately(detailModel.genreList, detailAdapter::submitList)
|
collectImmediately(detailModel.genreList, ::updateList)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||||
|
|
@ -120,26 +120,25 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Music) {
|
||||||
when (music) {
|
when (item) {
|
||||||
is Artist -> navModel.exploreNavigateTo(music)
|
is Artist -> navModel.exploreNavigateTo(item)
|
||||||
is Song ->
|
is Song -> {
|
||||||
when (Settings(requireContext()).detailPlaybackMode) {
|
val playbackMode = detailModel.playbackMode
|
||||||
// When configured to play from the selected item, we already have a Genre
|
if (playbackMode != null) {
|
||||||
|
playbackModel.playFrom(item, playbackMode)
|
||||||
|
} else {
|
||||||
|
// When configured to play from the selected item, we already have an Genre
|
||||||
// to play from.
|
// to play from.
|
||||||
null ->
|
playbackModel.playFromGenre(
|
||||||
playbackModel.playFromGenre(
|
item, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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) {
|
when (item) {
|
||||||
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
||||||
is Song -> openMusicMenu(anchor, R.menu.menu_song_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) {
|
override fun onOpenSortMenu(anchor: View) {
|
||||||
openMenu(anchor, R.menu.menu_genre_sort) {
|
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(sort.mode.itemId)).isChecked = true
|
||||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||||
setOnMenuItemClickListener { item ->
|
setOnMenuItemClickListener { item ->
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
detailModel.genreSort =
|
detailModel.genreSongSort =
|
||||||
if (item.itemId == R.id.option_sort_asc) {
|
if (item.itemId == R.id.option_sort_asc) {
|
||||||
sort.withAscending(item.isChecked)
|
sort.withAscending(item.isChecked)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -184,17 +183,15 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
var item: Item? = null
|
var playingMusic: Music? = null
|
||||||
|
|
||||||
if (parent is Artist) {
|
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) {
|
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
|
||||||
item = song
|
playingMusic = song
|
||||||
}
|
}
|
||||||
|
detailAdapter.setPlaying(playingMusic, isPlaying)
|
||||||
detailAdapter.setPlayingItem(item, isPlaying)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
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>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
detailAdapter.setSelectedItems(selected)
|
detailAdapter.setSelected(selected.toSet())
|
||||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import org.oxycblt.auxio.R
|
||||||
*
|
*
|
||||||
* Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
* Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ReadOnlyTextInput
|
class ReadOnlyTextInput
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
|
||||||
import org.oxycblt.auxio.detail.DiscHeader
|
import org.oxycblt.auxio.detail.DiscHeader
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
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
|
* An extension to [DetailAdapter.Listener] that enables interactions specific to the album
|
||||||
* detail view.
|
* 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
|
* Called when the artist name in the [Album] header was clicked, requesting navigation to
|
||||||
* it's parent artist.
|
* it's parent artist.
|
||||||
|
|
@ -57,7 +57,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
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.
|
// Support the Album header, sub-headers for each disc, and special album songs.
|
||||||
is Album -> AlbumDetailViewHolder.VIEW_TYPE
|
is Album -> AlbumDetailViewHolder.VIEW_TYPE
|
||||||
is DiscHeader -> DiscHeaderViewHolder.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) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
super.onBindViewHolder(holder, position)
|
super.onBindViewHolder(holder, position)
|
||||||
when (val item = differ.currentList[position]) {
|
when (val item = getItem(position)) {
|
||||||
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
|
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
|
||||||
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
||||||
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
|
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 {
|
override fun isItemFullWidth(position: Int): Boolean {
|
||||||
|
if (super.isItemFullWidth(position)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// The album and disc headers should be full-width in all configurations.
|
// The album and disc headers should be full-width in all configurations.
|
||||||
val item = differ.currentList[position]
|
val item = getItem(position)
|
||||||
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
|
return item is Album || item is DiscHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
oldItem is Album && newItem is Album ->
|
oldItem is Album && newItem is Album ->
|
||||||
|
|
@ -126,7 +129,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
|
||||||
binding.detailCover.bind(album)
|
binding.detailCover.bind(album)
|
||||||
|
|
||||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
// 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)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleDiffCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.areArtistContentsTheSame(newItem) &&
|
oldItem.areArtistContentsTheSame(newItem) &&
|
||||||
oldItem.dates == newItem.dates &&
|
oldItem.dates == newItem.dates &&
|
||||||
oldItem.songs.size == newItem.songs.size &&
|
oldItem.songs.size == newItem.songs.size &&
|
||||||
oldItem.durationMs == newItem.durationMs &&
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<DiscHeader>() {
|
object : SimpleDiffCallback<DiscHeader>() {
|
||||||
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
|
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
|
||||||
oldItem.disc == newItem.disc
|
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 song The new [Song] to bind.
|
||||||
* @param listener A [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
|
|
||||||
binding.songTrack.apply {
|
binding.songTrack.apply {
|
||||||
|
|
@ -274,7 +277,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
|
||||||
|
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
|
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.databinding.ItemSongBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
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.
|
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (differ.currentList[position]) {
|
when (getItem(position)) {
|
||||||
// Support an artist header, and special artist albums/songs.
|
// Support an artist header, and special artist albums/songs.
|
||||||
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
|
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
|
||||||
is Album -> ArtistAlbumViewHolder.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) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
super.onBindViewHolder(holder, position)
|
super.onBindViewHolder(holder, position)
|
||||||
// Re-binding an item with new data and not just a changed selection/playing state.
|
// 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 Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
|
||||||
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
|
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
|
||||||
is Song -> (holder as ArtistSongViewHolder).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 {
|
override fun isItemFullWidth(position: Int): Boolean {
|
||||||
|
if (super.isItemFullWidth(position)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Artist headers should be full-width in all configurations.
|
// Artist headers should be full-width in all configurations.
|
||||||
val item = differ.currentList[position]
|
return getItem(position) is Artist
|
||||||
return super.isItemFullWidth(position) || item is Artist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
oldItem is Artist && newItem is Artist ->
|
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 artist The new [Artist] to bind.
|
||||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
* @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.detailCover.bind(artist)
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
|
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
|
||||||
binding.detailName.text = artist.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Artist>() {
|
object : SimpleDiffCallback<Artist>() {
|
||||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.areGenreContentsTheSame(newItem) &&
|
oldItem.areGenreContentsTheSame(newItem) &&
|
||||||
|
|
@ -183,7 +187,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
||||||
* @param album The new [Album] to bind.
|
* @param album The new [Album] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
binding.parentImage.bind(album)
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleDiffCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
|
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 song The new [Song] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.album.rawName == newItem.album.rawName
|
oldItem.album.rawName == newItem.album.rawName
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ package org.oxycblt.auxio.detail.recycler
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.widget.TooltipCompat
|
import androidx.appcompat.widget.TooltipCompat
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.IntegerTable
|
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.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
|
import org.oxycblt.auxio.list.adapter.*
|
||||||
import org.oxycblt.auxio.list.recycler.*
|
import org.oxycblt.auxio.list.recycler.*
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
|
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
|
||||||
* @param listener A [Listener] to bind interactions to.
|
* @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.
|
* internal list.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
abstract class DetailAdapter(
|
abstract class DetailAdapter(
|
||||||
private val listener: Listener,
|
private val listener: Listener<*>,
|
||||||
itemCallback: DiffUtil.ItemCallback<Item>
|
diffCallback: DiffUtil.ItemCallback<Item>
|
||||||
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
) :
|
||||||
// Safe to leak this since the listener will not fire during initialization
|
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
|
||||||
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
|
ListDiffer.Async(diffCallback)),
|
||||||
|
AuxioRecyclerView.SpanSizeLookup {
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (differ.currentList[position]) {
|
when (getItem(position)) {
|
||||||
// Implement support for headers and sort headers
|
// Implement support for headers and sort headers
|
||||||
is Header -> HeaderViewHolder.VIEW_TYPE
|
is Header -> HeaderViewHolder.VIEW_TYPE
|
||||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||||
|
|
@ -63,7 +65,7 @@ abstract class DetailAdapter(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
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 Header -> (holder as HeaderViewHolder).bind(item)
|
||||||
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
|
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
|
||||||
}
|
}
|
||||||
|
|
@ -71,24 +73,12 @@ abstract class DetailAdapter(
|
||||||
|
|
||||||
override fun isItemFullWidth(position: Int): Boolean {
|
override fun isItemFullWidth(position: Int): Boolean {
|
||||||
// Headers should be full-width in all configurations.
|
// Headers should be full-width in all configurations.
|
||||||
val item = differ.currentList[position]
|
val item = getItem(position)
|
||||||
return item is Header || item is SortHeader
|
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. */
|
/** 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.
|
// 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
|
* 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 {
|
protected companion object {
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
oldItem is Header && newItem is Header ->
|
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 sortHeader The new [SortHeader] to bind.
|
||||||
* @param listener An [DetailAdapter.Listener] to bind interactions to.
|
* @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.headerTitle.text = binding.context.getString(sortHeader.titleRes)
|
||||||
binding.headerButton.apply {
|
binding.headerButton.apply {
|
||||||
// Add a Tooltip based on the content description so that the purpose of this
|
// 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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<SortHeader>() {
|
object : SimpleDiffCallback<SortHeader>() {
|
||||||
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
||||||
oldItem.titleRes == newItem.titleRes
|
oldItem.titleRes == newItem.titleRes
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,12 @@ import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailBinding
|
import org.oxycblt.auxio.databinding.ItemDetailBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
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.ArtistViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
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.
|
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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) =
|
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
|
// 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
|
// a genre that will make the artists/songs specially formatted, so it doesn't matter
|
||||||
// use for their ViewHolders.
|
// what we use for their ViewHolders.
|
||||||
is Genre -> GenreDetailViewHolder.VIEW_TYPE
|
is Genre -> GenreDetailViewHolder.VIEW_TYPE
|
||||||
is Artist -> ArtistViewHolder.VIEW_TYPE
|
is Artist -> ArtistViewHolder.VIEW_TYPE
|
||||||
is Song -> SongViewHolder.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) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
super.onBindViewHolder(holder, position)
|
super.onBindViewHolder(holder, position)
|
||||||
when (val item = differ.currentList[position]) {
|
when (val item = getItem(position)) {
|
||||||
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
||||||
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
|
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
|
||||||
is Song -> (holder as SongViewHolder).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 {
|
override fun isItemFullWidth(position: Int): Boolean {
|
||||||
|
if (super.isItemFullWidth(position)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Genre headers should be full-width in all configurations
|
// Genre headers should be full-width in all configurations
|
||||||
val item = differ.currentList[position]
|
return getItem(position) is Genre
|
||||||
return super.isItemFullWidth(position) || item is Genre
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
oldItem is Genre && newItem is Genre ->
|
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 genre The new [Song] to bind.
|
||||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
* @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.detailCover.bind(genre)
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
||||||
binding.detailName.text = genre.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Genre>() {
|
object : SimpleDiffCallback<Genre>() {
|
||||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.songs.size == newItem.songs.size &&
|
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.home.tabs.AdaptiveTabStrategy
|
||||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||||
import org.oxycblt.auxio.music.*
|
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.music.system.Indexer
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
|
|
@ -143,7 +145,7 @@ class HomeFragment :
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
collect(homeModel.shouldRecreate, ::handleRecreate)
|
collect(homeModel.shouldRecreate, ::handleRecreate)
|
||||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||||
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
|
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
||||||
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
||||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
|
|
@ -333,10 +335,7 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCompleteState(
|
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
||||||
binding: FragmentHomeBinding,
|
|
||||||
result: Result<MusicStore.Library>
|
|
||||||
) {
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
logD("Received ok response")
|
logD("Received ok response")
|
||||||
binding.homeFab.show()
|
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
|
package org.oxycblt.auxio.home
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,15 +34,15 @@ import org.oxycblt.auxio.util.logD
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class HomeViewModel(application: Application) :
|
class HomeViewModel(application: Application) :
|
||||||
AndroidViewModel(application),
|
AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener {
|
||||||
MusicStore.Listener,
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
private val musicStore = MusicStore.getInstance()
|
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>())
|
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** 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
|
get() = _songsList
|
||||||
|
|
||||||
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
||||||
|
|
@ -70,11 +64,15 @@ class HomeViewModel(application: Application) :
|
||||||
val genresList: StateFlow<List<Genre>>
|
val genresList: StateFlow<List<Genre>>
|
||||||
get() = _genresList
|
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
|
* A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
|
||||||
* [Tab]s.
|
* [Tab]s.
|
||||||
*/
|
*/
|
||||||
var currentTabModes: List<MusicMode> = makeTabModes()
|
var currentTabModes = makeTabModes()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
|
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
|
||||||
|
|
@ -95,45 +93,82 @@ class HomeViewModel(application: Application) :
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addListener(this)
|
musicStore.addListener(this)
|
||||||
settings.addListener(this)
|
homeSettings.registerListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
settings.removeListener(this)
|
homeSettings.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
logD("Library changed, refreshing library")
|
logD("Library changed, refreshing library")
|
||||||
// Get the each list of items in the library to use as our list data.
|
// Get the each list of items in the library to use as our list data.
|
||||||
// Applying the preferred sorting to them.
|
// Applying the preferred sorting to them.
|
||||||
_songsList.value = settings.libSongSort.songs(library.songs)
|
_songsList.value = musicSettings.songSort.songs(library.songs)
|
||||||
_albumsLists.value = settings.libAlbumSort.albums(library.albums)
|
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
|
||||||
_artistsList.value =
|
_artistsList.value =
|
||||||
settings.libArtistSort.artists(
|
musicSettings.artistSort.artists(
|
||||||
if (settings.shouldHideCollaborators) {
|
if (homeSettings.shouldHideCollaborators) {
|
||||||
// Hide Collaborators is enabled, filter out collaborators.
|
// Hide Collaborators is enabled, filter out collaborators.
|
||||||
library.artists.filter { !it.isCollaborator }
|
library.artists.filter { !it.isCollaborator }
|
||||||
} else {
|
} else {
|
||||||
library.artists
|
library.artists
|
||||||
})
|
})
|
||||||
_genresList.value = settings.libGenreSort.genres(library.genres)
|
_genresList.value = musicSettings.genreSort.genres(library.genres)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onTabsChanged() {
|
||||||
when (key) {
|
// Tabs changed, update the current tabs and set up a re-create event.
|
||||||
context.getString(R.string.set_key_lib_tabs) -> {
|
currentTabModes = makeTabModes()
|
||||||
// Tabs changed, update the current tabs and set up a re-create event.
|
_shouldRecreate.value = true
|
||||||
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) -> {
|
MusicMode.ALBUMS -> {
|
||||||
// Changes in the hide collaborator setting will change the artist contents
|
musicSettings.albumSort = sort
|
||||||
// of the library, consider it a library update.
|
_albumsLists.value = sort.albums(_albumsLists.value)
|
||||||
onLibraryChanged(musicStore.library)
|
}
|
||||||
|
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
|
_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.
|
* 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.
|
* @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
|
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
|
||||||
* the same way as the configuration.
|
* 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.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
import org.oxycblt.auxio.list.*
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
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.AlbumViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.Sort
|
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
import org.oxycblt.auxio.playback.secsToMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
@ -47,7 +45,7 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class AlbumListFragment :
|
class AlbumListFragment :
|
||||||
ListFragment<FragmentHomeListBinding>(),
|
ListFragment<Album, FragmentHomeListBinding>(),
|
||||||
FastScrollRecyclerView.Listener,
|
FastScrollRecyclerView.Listener,
|
||||||
FastScrollRecyclerView.PopupProvider {
|
FastScrollRecyclerView.PopupProvider {
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
|
|
@ -69,8 +67,8 @@ class AlbumListFragment :
|
||||||
listener = this@AlbumListFragment
|
listener = this@AlbumListFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
collectImmediately(homeModel.albumsList, albumAdapter::replaceList)
|
collectImmediately(homeModel.albumsList, ::updateList)
|
||||||
collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,45 +123,40 @@ class AlbumListFragment :
|
||||||
homeModel.setFastScrolling(isFastScrolling)
|
homeModel.setFastScrolling(isFastScrolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Album) {
|
||||||
check(music is Album) { "Unexpected datatype: ${music::class.java}" }
|
navModel.exploreNavigateTo(item)
|
||||||
navModel.exploreNavigateTo(music)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Album, anchor: View) {
|
||||||
check(item is Album) { "Unexpected datatype: ${item::class.java}" }
|
|
||||||
openMusicMenu(anchor, R.menu.menu_album_actions, item)
|
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) {
|
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If an album is playing, highlight it within this adapter.
|
// 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].
|
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
private class AlbumAdapter(private val listener: SelectableListListener) :
|
private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
|
||||||
SelectionIndicatorAdapter<AlbumViewHolder>() {
|
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>(
|
||||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)
|
ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) {
|
||||||
|
|
||||||
override val currentList: List<Item>
|
|
||||||
get() = differ.currentList
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
AlbumViewHolder.from(parent)
|
AlbumViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(getItem(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
import org.oxycblt.auxio.list.*
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
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.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.Artist
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
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.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
@ -45,11 +46,11 @@ import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ArtistListFragment :
|
class ArtistListFragment :
|
||||||
ListFragment<FragmentHomeListBinding>(),
|
ListFragment<Artist, FragmentHomeListBinding>(),
|
||||||
FastScrollRecyclerView.PopupProvider,
|
FastScrollRecyclerView.PopupProvider,
|
||||||
FastScrollRecyclerView.Listener {
|
FastScrollRecyclerView.Listener {
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val homeAdapter = ArtistAdapter(this)
|
private val artistAdapter = ArtistAdapter(this)
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
FragmentHomeListBinding.inflate(inflater)
|
FragmentHomeListBinding.inflate(inflater)
|
||||||
|
|
@ -59,13 +60,13 @@ class ArtistListFragment :
|
||||||
|
|
||||||
binding.homeRecycler.apply {
|
binding.homeRecycler.apply {
|
||||||
id = R.id.home_artist_recycler
|
id = R.id.home_artist_recycler
|
||||||
adapter = homeAdapter
|
adapter = artistAdapter
|
||||||
popupProvider = this@ArtistListFragment
|
popupProvider = this@ArtistListFragment
|
||||||
listener = this@ArtistListFragment
|
listener = this@ArtistListFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
collectImmediately(homeModel.artistsList, homeAdapter::replaceList)
|
collectImmediately(homeModel.artistsList, ::updateList)
|
||||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,45 +101,40 @@ class ArtistListFragment :
|
||||||
homeModel.setFastScrolling(isFastScrolling)
|
homeModel.setFastScrolling(isFastScrolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Artist) {
|
||||||
check(music is Artist) { "Unexpected datatype: ${music::class.java}" }
|
navModel.exploreNavigateTo(item)
|
||||||
navModel.exploreNavigateTo(music)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Artist, anchor: View) {
|
||||||
check(item is Artist) { "Unexpected datatype: ${item::class.java}" }
|
|
||||||
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
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) {
|
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If an artist is playing, highlight it within this adapter.
|
// 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].
|
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
private class ArtistAdapter(private val listener: SelectableListListener) :
|
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
|
||||||
SelectionIndicatorAdapter<ArtistViewHolder>() {
|
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>(
|
||||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)
|
ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) {
|
||||||
|
|
||||||
override val currentList: List<Item>
|
|
||||||
get() = differ.currentList
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
ArtistViewHolder.from(parent)
|
ArtistViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(getItem(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
import org.oxycblt.auxio.list.*
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
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.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.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
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.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
||||||
|
|
@ -44,11 +45,11 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenreListFragment :
|
class GenreListFragment :
|
||||||
ListFragment<FragmentHomeListBinding>(),
|
ListFragment<Genre, FragmentHomeListBinding>(),
|
||||||
FastScrollRecyclerView.PopupProvider,
|
FastScrollRecyclerView.PopupProvider,
|
||||||
FastScrollRecyclerView.Listener {
|
FastScrollRecyclerView.Listener {
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val homeAdapter = GenreAdapter(this)
|
private val genreAdapter = GenreAdapter(this)
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||||
FragmentHomeListBinding.inflate(inflater)
|
FragmentHomeListBinding.inflate(inflater)
|
||||||
|
|
@ -58,13 +59,13 @@ class GenreListFragment :
|
||||||
|
|
||||||
binding.homeRecycler.apply {
|
binding.homeRecycler.apply {
|
||||||
id = R.id.home_genre_recycler
|
id = R.id.home_genre_recycler
|
||||||
adapter = homeAdapter
|
adapter = genreAdapter
|
||||||
popupProvider = this@GenreListFragment
|
popupProvider = this@GenreListFragment
|
||||||
listener = this@GenreListFragment
|
listener = this@GenreListFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
collectImmediately(homeModel.genresList, homeAdapter::replaceList)
|
collectImmediately(homeModel.genresList, ::updateList)
|
||||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,45 +100,39 @@ class GenreListFragment :
|
||||||
homeModel.setFastScrolling(isFastScrolling)
|
homeModel.setFastScrolling(isFastScrolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Genre) {
|
||||||
check(music is Genre) { "Unexpected datatype: ${music::class.java}" }
|
navModel.exploreNavigateTo(item)
|
||||||
navModel.exploreNavigateTo(music)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Genre, anchor: View) {
|
||||||
check(item is Genre) { "Unexpected datatype: ${item::class.java}" }
|
|
||||||
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
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) {
|
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// If a genre is playing, highlight it within this adapter.
|
// 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].
|
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
private class GenreAdapter(private val listener: SelectableListListener) :
|
private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
|
||||||
SelectionIndicatorAdapter<GenreViewHolder>() {
|
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>(
|
||||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)
|
ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) {
|
||||||
|
|
||||||
override val currentList: List<Item>
|
|
||||||
get() = differ.currentList
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
GenreViewHolder.from(parent)
|
GenreViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(getItem(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,17 @@ import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
import org.oxycblt.auxio.list.*
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
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.SongViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
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.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
import org.oxycblt.auxio.playback.secsToMs
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,11 +48,11 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SongListFragment :
|
class SongListFragment :
|
||||||
ListFragment<FragmentHomeListBinding>(),
|
ListFragment<Song, FragmentHomeListBinding>(),
|
||||||
FastScrollRecyclerView.PopupProvider,
|
FastScrollRecyclerView.PopupProvider,
|
||||||
FastScrollRecyclerView.Listener {
|
FastScrollRecyclerView.Listener {
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
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
|
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||||
private val formatterSb = StringBuilder(64)
|
private val formatterSb = StringBuilder(64)
|
||||||
private val formatter = Formatter(formatterSb)
|
private val formatter = Formatter(formatterSb)
|
||||||
|
|
@ -65,13 +65,13 @@ class SongListFragment :
|
||||||
|
|
||||||
binding.homeRecycler.apply {
|
binding.homeRecycler.apply {
|
||||||
id = R.id.home_song_recycler
|
id = R.id.home_song_recycler
|
||||||
adapter = homeAdapter
|
adapter = songAdapter
|
||||||
popupProvider = this@SongListFragment
|
popupProvider = this@SongListFragment
|
||||||
listener = this@SongListFragment
|
listener = this@SongListFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
collectImmediately(homeModel.songLists, homeAdapter::replaceList)
|
collectImmediately(homeModel.songsList, ::updateList)
|
||||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +86,7 @@ class SongListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
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.
|
// 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
|
// 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.
|
// based off the names of the parent objects and not the child objects.
|
||||||
|
|
@ -130,27 +130,28 @@ class SongListFragment :
|
||||||
homeModel.setFastScrolling(isFastScrolling)
|
homeModel.setFastScrolling(isFastScrolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Song) {
|
||||||
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
|
playbackModel.playFrom(item, homeModel.playbackMode)
|
||||||
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 onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Song, anchor: View) {
|
||||||
check(item is Song) { "Unexpected datatype: ${item::class.java}" }
|
|
||||||
openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
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) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
homeAdapter.setPlayingItem(song, isPlaying)
|
songAdapter.setPlaying(song, isPlaying)
|
||||||
} else {
|
} else {
|
||||||
// Ignore playback that is not from all songs
|
// 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].
|
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
private class SongAdapter(private val listener: SelectableListListener) :
|
private class SongAdapter(private val listener: SelectableListListener<Song>) :
|
||||||
SelectionIndicatorAdapter<SongViewHolder>() {
|
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>(
|
||||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
|
ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) {
|
||||||
|
|
||||||
override val currentList: List<Item>
|
|
||||||
get() = differ.currentList
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
SongViewHolder.from(parent)
|
SongViewHolder.from(parent)
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
||||||
holder.bind(differ.currentList[position], listener)
|
holder.bind(getItem(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.home.tabs
|
package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.util.logE
|
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.
|
* @param mode The type of list in the home view this instance corresponds to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* A visible tab. This will be visible in the home and tab configuration views.
|
||||||
* @param mode The type of list in the home view this instance corresponds to.
|
* @param 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.
|
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||||
* @param listener A [EditableListListener] for tab interactions.
|
* @param listener A [EditableListListener] for tab interactions.
|
||||||
*/
|
*/
|
||||||
class TabAdapter(private val listener: EditableListListener) :
|
class TabAdapter(private val listener: EditableListListener<Tab>) :
|
||||||
RecyclerView.Adapter<TabViewHolder>() {
|
RecyclerView.Adapter<TabViewHolder>() {
|
||||||
/** The current array of [Tab]s. */
|
/** The current array of [Tab]s. */
|
||||||
var tabs = arrayOf<Tab>()
|
var tabs = arrayOf<Tab>()
|
||||||
|
|
@ -93,7 +93,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||||
* @param listener A [EditableListListener] to bind interactions to.
|
* @param listener A [EditableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(tab: Tab, listener: EditableListListener) {
|
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
|
||||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||||
binding.tabCheckBox.apply {
|
binding.tabCheckBox.apply {
|
||||||
// Update the CheckBox name to align with the mode
|
// 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.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||||
|
import org.oxycblt.auxio.home.HomeSettings
|
||||||
import org.oxycblt.auxio.list.EditableListListener
|
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.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.logD
|
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.
|
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener {
|
class TabCustomizeDialog :
|
||||||
|
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
|
||||||
private val tabAdapter = TabAdapter(this)
|
private val tabAdapter = TabAdapter(this)
|
||||||
private var touchHelper: ItemTouchHelper? = null
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
|
|
||||||
|
|
@ -46,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), Edita
|
||||||
.setTitle(R.string.set_lib_tabs)
|
.setTitle(R.string.set_lib_tabs)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
logD("Committing tab changes")
|
logD("Committing tab changes")
|
||||||
Settings(requireContext()).libTabs = tabAdapter.tabs
|
HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
|
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.
|
// Try to restore a pending tab configuration that was saved prior.
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
||||||
|
|
@ -81,8 +81,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), Edita
|
||||||
binding.tabRecycler.adapter = null
|
binding.tabRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Tab, viewHolder: RecyclerView.ViewHolder) {
|
||||||
check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
|
|
||||||
// We will need the exact index of the tab to update on in order to
|
// We will need the exact index of the tab to update on in order to
|
||||||
// notify the adapter of the change.
|
// notify the adapter of the change.
|
||||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }
|
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 com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import org.oxycblt.auxio.R
|
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.getColorCompat
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
|
|
||||||
|
|
@ -52,7 +52,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
private val indicatorMatrix = Matrix()
|
private val indicatorMatrix = Matrix()
|
||||||
private val indicatorMatrixSrc = RectF()
|
private val indicatorMatrixSrc = RectF()
|
||||||
private val indicatorMatrixDst = 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
|
* 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) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
(background as? MaterialShapeDrawable)?.let { bg ->
|
(background as? MaterialShapeDrawable)?.let { bg ->
|
||||||
if (settings.roundMode) {
|
if (UISettings.from(context).roundMode) {
|
||||||
bg.setCornerSize(value)
|
bg.setCornerSize(value)
|
||||||
} else {
|
} else {
|
||||||
bg.setCornerSize(0f)
|
bg.setCornerSize(0f)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
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.getColorCompat
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
background =
|
background =
|
||||||
MaterialShapeDrawable().apply {
|
MaterialShapeDrawable().apply {
|
||||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
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.
|
// Only use the specified corner radius when round mode is enabled.
|
||||||
setCornerSize(cornerRadius)
|
setCornerSize(cornerRadius)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* A [Keyer] implementation for [Music] data.
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import java.io.InputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
|
@ -47,10 +47,8 @@ object Covers {
|
||||||
* loading failed or should not occur.
|
* loading failed or should not occur.
|
||||||
*/
|
*/
|
||||||
suspend fun fetch(context: Context, album: Album): InputStream? {
|
suspend fun fetch(context: Context, album: Album): InputStream? {
|
||||||
val settings = Settings(context)
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
when (settings.coverMode) {
|
when (ImageSettings.from(context).coverMode) {
|
||||||
CoverMode.OFF -> null
|
CoverMode.OFF -> null
|
||||||
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
|
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
|
||||||
CoverMode.QUALITY -> fetchQualityCovers(context, album)
|
CoverMode.QUALITY -> fetchQualityCovers(context, album)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ import org.oxycblt.auxio.util.showToast
|
||||||
* A Fragment containing a selectable list.
|
* A Fragment containing a selectable list.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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()
|
protected val navModel: NavigationViewModel by activityViewModels()
|
||||||
private var currentMenu: PopupMenu? = null
|
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
|
* 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].
|
* 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) {
|
override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
|
||||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
if (selectionModel.selected.value.isNotEmpty()) {
|
if (selectionModel.selected.value.isNotEmpty()) {
|
||||||
// Map clicking an item to selecting an item when items are already selected.
|
// Map clicking an item to selecting an item when items are already selected.
|
||||||
selectionModel.select(item)
|
selectionModel.select(item)
|
||||||
|
|
@ -65,8 +65,7 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSelect(item: Item) {
|
override fun onSelect(item: T) {
|
||||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
selectionModel.select(item)
|
selectionModel.select(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,26 +25,22 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
* A basic listener for list interactions.
|
* A basic listener for list interactions.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface ClickableListListener {
|
interface ClickableListListener<in T> {
|
||||||
/**
|
/**
|
||||||
* Called when an [Item] in the list is clicked.
|
* Called when an item in the list is clicked.
|
||||||
* @param item The [Item] that was clicked.
|
* @param item The [T] item that was clicked.
|
||||||
* @param viewHolder The [RecyclerView.ViewHolder] of the 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.
|
* 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 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
|
* @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.
|
* this [View] are routed to the listener. Defaults to the root view.
|
||||||
*/
|
*/
|
||||||
fun bind(
|
fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) {
|
||||||
item: Item,
|
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
|
||||||
bodyView: View = viewHolder.itemView
|
|
||||||
) {
|
|
||||||
bodyView.setOnClickListener { onClick(item, viewHolder) }
|
bodyView.setOnClickListener { onClick(item, viewHolder) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +49,7 @@ interface ClickableListListener {
|
||||||
* An extension of [ClickableListListener] that enables list editing functionality.
|
* An extension of [ClickableListListener] that enables list editing functionality.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface EditableListListener : ClickableListListener {
|
interface EditableListListener<in T> : ClickableListListener<T> {
|
||||||
/**
|
/**
|
||||||
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
|
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
|
||||||
* @param viewHolder The [RecyclerView.ViewHolder] that should start being 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.
|
* 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 viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||||
* @param bodyView The [View] containing the main body of the list item. Any click events on
|
* @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.
|
* 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.
|
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
|
||||||
*/
|
*/
|
||||||
fun bind(
|
fun bind(
|
||||||
item: Item,
|
item: T,
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
bodyView: View = viewHolder.itemView,
|
bodyView: View = viewHolder.itemView,
|
||||||
dragHandle: View
|
dragHandle: View
|
||||||
|
|
@ -89,30 +85,30 @@ interface EditableListListener : ClickableListListener {
|
||||||
* An extension of [ClickableListListener] that enables menu and selection functionality.
|
* An extension of [ClickableListListener] that enables menu and selection functionality.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* 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.
|
* @param item The [T] item to open a menu for.
|
||||||
* @param anchor The [View] to anchor the menu to.
|
* @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.
|
* Called when an item in the list requests that it be selected.
|
||||||
* @param item The [Item] to select.
|
* @param item The [T] item to select.
|
||||||
*/
|
*/
|
||||||
fun onSelect(item: Item)
|
fun onSelect(item: T)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds this instance to a list item.
|
* 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 viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||||
* @param bodyView The [View] containing the main body of the list item. Any click events on
|
* @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.
|
* 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.
|
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
|
||||||
*/
|
*/
|
||||||
fun bind(
|
fun bind(
|
||||||
item: Item,
|
item: T,
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
bodyView: View = viewHolder.itemView,
|
bodyView: View = viewHolder.itemView,
|
||||||
menuButton: View
|
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/>.
|
* 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 android.view.View
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
* 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)
|
* @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:
|
// There are actually two states for this adapter:
|
||||||
// - The currently playing item, which is usually marked as "selected" and becomes accented.
|
// - 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
|
// - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is
|
||||||
// marked as "playing" or not.
|
// marked as "playing" or not.
|
||||||
private var currentItem: Item? = null
|
private var currentItem: T? = null
|
||||||
private var isPlaying = false
|
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>) {
|
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||||
// Only try to update the playing indicator if the ViewHolder supports it
|
// Only try to update the playing indicator if the ViewHolder supports it
|
||||||
if (holder is ViewHolder) {
|
if (holder is ViewHolder) {
|
||||||
|
|
@ -55,10 +50,10 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Update the currently playing item in the list.
|
* 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.
|
* @param isPlaying Whether playback is ongoing or paused.
|
||||||
*/
|
*/
|
||||||
fun setPlayingItem(item: Item?, isPlaying: Boolean) {
|
fun setPlaying(item: T?, isPlaying: Boolean) {
|
||||||
var updatedItem = false
|
var updatedItem = false
|
||||||
if (currentItem != item) {
|
if (currentItem != item) {
|
||||||
val oldItem = currentItem
|
val oldItem = currentItem
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 android.view.View
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
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
|
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||||
* items.
|
* items.
|
||||||
|
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
|
||||||
PlayingIndicatorAdapter<VH>() {
|
differFactory: ListDiffer.Factory<T, I>
|
||||||
private var selectedItems = setOf<Music>()
|
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) {
|
||||||
|
private var selectedItems = setOf<T>()
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||||
super.onBindViewHolder(holder, position, payloads)
|
super.onBindViewHolder(holder, position, payloads)
|
||||||
|
|
@ -39,9 +41,9 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the list of selected items.
|
* 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 oldSelectedItems = selectedItems
|
||||||
val newSelectedItems = items.toSet()
|
val newSelectedItems = items.toSet()
|
||||||
if (newSelectedItems == oldSelectedItems) {
|
if (newSelectedItems == oldSelectedItems) {
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 androidx.recyclerview.widget.DiffUtil
|
||||||
import org.oxycblt.auxio.list.Item
|
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.
|
* whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
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,
|
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
|
||||||
// so we can enable fixed-size optimizations.
|
// so we can enable fixed-size optimizations.
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
|
addItemDecoration(HeaderItemDecoration(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
||||||
|
|
@ -52,6 +53,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
super.setHasFixedSize(hasFixedSize)
|
super.setHasFixedSize(hasFixedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final override fun addItemDecoration(decor: ItemDecoration) {
|
||||||
|
super.addItemDecoration(decor)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||||
// Update the RecyclerView's padding such that the bottom insets are applied
|
// Update the RecyclerView's padding such that the bottom insets are applied
|
||||||
// while still preserving bottom padding.
|
// 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]. */
|
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
|
||||||
interface 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.databinding.ItemSongBinding
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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 song The new [Song] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(song, this, menuButton = binding.songMenu)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Song>() {
|
object : SimpleDiffCallback<Song>() {
|
||||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem)
|
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 album The new [Album] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(album, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(album)
|
binding.parentImage.bind(album)
|
||||||
binding.parentName.text = album.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Album>() {
|
object : SimpleDiffCallback<Album>() {
|
||||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.areArtistContentsTheSame(newItem) &&
|
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 artist The new [Artist] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(artist, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(artist)
|
binding.parentImage.bind(artist)
|
||||||
binding.parentName.text = artist.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Artist>() {
|
object : SimpleDiffCallback<Artist>() {
|
||||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||||
oldItem.rawName == newItem.rawName &&
|
oldItem.rawName == newItem.rawName &&
|
||||||
oldItem.albums.size == newItem.albums.size &&
|
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 genre The new [Genre] to bind.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @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)
|
listener.bind(genre, this, menuButton = binding.parentMenu)
|
||||||
binding.parentImage.bind(genre)
|
binding.parentImage.bind(genre)
|
||||||
binding.parentName.text = genre.resolveName(binding.context)
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Genre>() {
|
object : SimpleDiffCallback<Genre>() {
|
||||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
|
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
|
||||||
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
|
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.
|
* @param header The new [Header] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(header: Header) {
|
fun bind(header: Header) {
|
||||||
|
logD(binding.context.getString(header.titleRes))
|
||||||
binding.title.text = 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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Header>() {
|
object : SimpleDiffCallback<Header>() {
|
||||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||||
oldItem.titleRes == newItem.titleRes
|
oldItem.titleRes == newItem.titleRes
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_selection_play -> {
|
||||||
|
playbackModel.play(selectionModel.consume())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_selection_shuffle -> {
|
||||||
|
playbackModel.shuffle(selectionModel.consume())
|
||||||
|
true
|
||||||
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
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.
|
* A [ViewModel] that manages the current selection.
|
||||||
|
|
@ -38,7 +40,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener {
|
||||||
musicStore.addListener(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library == null) {
|
if (library == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.text.CollationKey
|
import java.text.CollationKey
|
||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
|
|
@ -30,10 +31,12 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Item
|
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.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
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.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
|
|
@ -308,10 +311,10 @@ sealed class MusicParent : Music() {
|
||||||
/**
|
/**
|
||||||
* A song. Perhaps the foundation of the entirety of Auxio.
|
* A song. Perhaps the foundation of the entirety of Auxio.
|
||||||
* @param raw The [Song.Raw] to derive the member data from.
|
* @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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Song constructor(raw: Raw, settings: Settings) : Music() {
|
class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
|
||||||
override val uid =
|
override val uid =
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
|
raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
|
||||||
|
|
@ -381,9 +384,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
val album: Album
|
val album: Album
|
||||||
get() = unlikelyToBeNull(_album)
|
get() = unlikelyToBeNull(_album)
|
||||||
|
|
||||||
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings)
|
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||||
private val artistNames = raw.artistNames.parseMultiValue(settings)
|
private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
|
||||||
private val artistSortNames = raw.artistSortNames.parseMultiValue(settings)
|
private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
|
||||||
private val rawArtists =
|
private val rawArtists =
|
||||||
artistNames.mapIndexed { i, name ->
|
artistNames.mapIndexed { i, name ->
|
||||||
Artist.Raw(
|
Artist.Raw(
|
||||||
|
|
@ -392,9 +395,10 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
artistSortNames.getOrNull(i))
|
artistSortNames.getOrNull(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings)
|
private val albumArtistMusicBrainzIds =
|
||||||
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)
|
raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||||
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings)
|
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings)
|
||||||
|
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings)
|
||||||
private val rawAlbumArtists =
|
private val rawAlbumArtists =
|
||||||
albumArtistNames.mapIndexed { i, name ->
|
albumArtistNames.mapIndexed { i, name ->
|
||||||
Artist.Raw(
|
Artist.Raw(
|
||||||
|
|
@ -462,7 +466,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
|
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
|
||||||
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
|
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
|
||||||
sortName = raw.albumSortName,
|
sortName = raw.albumSortName,
|
||||||
type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings)),
|
releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)),
|
||||||
rawArtists =
|
rawArtists =
|
||||||
rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
|
rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
|
||||||
|
|
||||||
|
|
@ -481,7 +485,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
*/
|
*/
|
||||||
val _rawGenres =
|
val _rawGenres =
|
||||||
raw.genreNames
|
raw.genreNames
|
||||||
.parseId3GenreNames(settings)
|
.parseId3GenreNames(musicSettings)
|
||||||
.map { Genre.Raw(it) }
|
.map { Genre.Raw(it) }
|
||||||
.ifEmpty { listOf(Genre.Raw()) }
|
.ifEmpty { listOf(Genre.Raw()) }
|
||||||
|
|
||||||
|
|
@ -581,8 +585,8 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
|
||||||
var albumName: String? = null,
|
var albumName: String? = null,
|
||||||
/** @see Album.Raw.sortName */
|
/** @see Album.Raw.sortName */
|
||||||
var albumSortName: String? = null,
|
var albumSortName: String? = null,
|
||||||
/** @see Album.Raw.type */
|
/** @see Album.Raw.releaseType */
|
||||||
var albumTypes: List<String> = listOf(),
|
var releaseTypes: List<String> = listOf(),
|
||||||
/** @see Artist.Raw.musicBrainzId */
|
/** @see Artist.Raw.musicBrainzId */
|
||||||
var artistMusicBrainzIds: List<String> = listOf(),
|
var artistMusicBrainzIds: List<String> = listOf(),
|
||||||
/** @see Artist.Raw.name */
|
/** @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 })
|
val dates = Date.Range.from(songs.mapNotNull { it.date })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The [Type] of this album, signifying the type of release it actually is. Defaults to
|
* The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
|
||||||
* [Type.Album].
|
* [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
|
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
|
||||||
* cost of image quality.
|
* 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
|
* Raw information about an [Album] obtained from the component [Song] instances. **This is only
|
||||||
* meant for use within the music package.**
|
* 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,
|
val name: String,
|
||||||
/** @see Music.rawSortName */
|
/** @see Music.rawSortName */
|
||||||
val sortName: String?,
|
val sortName: String?,
|
||||||
/** @see Album.type */
|
/** @see Album.releaseType */
|
||||||
val type: Type?,
|
val releaseType: ReleaseType?,
|
||||||
/** @see Artist.Raw.name */
|
/** @see Artist.Raw.name */
|
||||||
val rawArtists: List<Artist.Raw>
|
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 hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?) =
|
||||||
if (other !is Raw) return false
|
other is Raw &&
|
||||||
if (musicBrainzId != null &&
|
when {
|
||||||
other.musicBrainzId != null &&
|
musicBrainzId != null && other.musicBrainzId != null ->
|
||||||
musicBrainzId == other.musicBrainzId) {
|
musicBrainzId == other.musicBrainzId
|
||||||
return true
|
musicBrainzId == null && other.musicBrainzId == null ->
|
||||||
}
|
name.equals(other.name, true) && rawArtists == other.rawArtists
|
||||||
|
else -> false
|
||||||
return name.equals(other.name, true) && rawArtists == other.rawArtists
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1108,21 +916,19 @@ class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicP
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?) =
|
||||||
if (other !is Raw) return false
|
other is Raw &&
|
||||||
|
when {
|
||||||
if (musicBrainzId != null &&
|
musicBrainzId != null && other.musicBrainzId != null ->
|
||||||
other.musicBrainzId != null &&
|
musicBrainzId == other.musicBrainzId
|
||||||
musicBrainzId == other.musicBrainzId) {
|
musicBrainzId == null && other.musicBrainzId == null ->
|
||||||
return true
|
when {
|
||||||
}
|
name != null && other.name != null -> name.equals(other.name, true)
|
||||||
|
name == null && other.name == null -> true
|
||||||
return when {
|
else -> false
|
||||||
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.
|
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
|
||||||
* @see UUID.fromString
|
* @see UUID.fromString
|
||||||
*/
|
*/
|
||||||
fun String.toUuidOrNull(): UUID? =
|
private fun String.toUuidOrNull(): UUID? =
|
||||||
try {
|
try {
|
||||||
UUID.fromString(this)
|
UUID.fromString(this)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
|
|
@ -1228,7 +1034,8 @@ fun String.toUuidOrNull(): UUID? =
|
||||||
* Update a [MessageDigest] with a lowercase [String].
|
* Update a [MessageDigest] with a lowercase [String].
|
||||||
* @param string The [String] to hash. If null, it will not be hashed.
|
* @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) {
|
if (string != null) {
|
||||||
update(string.lowercase().toByteArray())
|
update(string.lowercase().toByteArray())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1240,7 +1047,8 @@ private fun MessageDigest.update(string: String?) {
|
||||||
* Update a [MessageDigest] with the string representation of a [Date].
|
* Update a [MessageDigest] with the string representation of a [Date].
|
||||||
* @param date The [Date] to hash. If null, nothing will be done.
|
* @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) {
|
if (date != null) {
|
||||||
update(date.toString().toByteArray())
|
update(date.toString().toByteArray())
|
||||||
} else {
|
} 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.
|
* 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.
|
* @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)
|
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].
|
* Update a [MessageDigest] with the little-endian bytes of a [Int].
|
||||||
* @param n The [Int] to write. If null, nothing will be done.
|
* @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) {
|
if (n != null) {
|
||||||
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
|
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
|
||||||
} else {
|
} 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
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
|
||||||
import org.oxycblt.auxio.music.filesystem.useQuery
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* 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
|
* 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
|
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||||
* the first place.
|
* the first place.
|
||||||
* @see Listener
|
* @see Listener
|
||||||
|
|
@ -72,101 +68,6 @@ class MusicStore private constructor() {
|
||||||
listeners.remove(listener)
|
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. */
|
/** A listener for changes in the music library. */
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import org.oxycblt.auxio.music.Date
|
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
||||||
import org.oxycblt.auxio.music.parsing.splitEscaped
|
import org.oxycblt.auxio.music.parsing.splitEscaped
|
||||||
|
import org.oxycblt.auxio.music.tags.Date
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -142,7 +142,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
|
||||||
rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
|
rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
|
||||||
rawSong.albumName = cachedRawSong.albumName
|
rawSong.albumName = cachedRawSong.albumName
|
||||||
rawSong.albumSortName = cachedRawSong.albumSortName
|
rawSong.albumSortName = cachedRawSong.albumSortName
|
||||||
rawSong.albumTypes = cachedRawSong.albumTypes
|
rawSong.releaseTypes = cachedRawSong.releaseTypes
|
||||||
|
|
||||||
rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
|
rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
|
||||||
rawSong.artistNames = cachedRawSong.artistNames
|
rawSong.artistNames = cachedRawSong.artistNames
|
||||||
|
|
@ -190,7 +190,7 @@ private class CacheDatabase(context: Context) :
|
||||||
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
|
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
|
||||||
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
|
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
|
||||||
append("${Columns.ALBUM_SORT_NAME} STRING,")
|
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_MUSIC_BRAINZ_IDS} STRING,")
|
||||||
append("${Columns.ARTIST_NAMES} STRING,")
|
append("${Columns.ARTIST_NAMES} STRING,")
|
||||||
append("${Columns.ARTIST_SORT_NAMES} STRING,")
|
append("${Columns.ARTIST_SORT_NAMES} STRING,")
|
||||||
|
|
@ -249,7 +249,7 @@ private class CacheDatabase(context: Context) :
|
||||||
cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
|
cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
|
||||||
val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
|
val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
|
||||||
val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_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 =
|
val artistMusicBrainzIdsIndex =
|
||||||
cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
|
cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
|
||||||
|
|
@ -286,8 +286,8 @@ private class CacheDatabase(context: Context) :
|
||||||
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
|
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
|
||||||
raw.albumName = cursor.getString(albumNameIndex)
|
raw.albumName = cursor.getString(albumNameIndex)
|
||||||
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
|
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
|
||||||
cursor.getStringOrNull(albumTypesIndex)?.let {
|
cursor.getStringOrNull(releaseTypesIndex)?.let {
|
||||||
raw.albumTypes = it.parseSQLMultiValue()
|
raw.releaseTypes = it.parseSQLMultiValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
|
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
|
||||||
|
|
@ -351,7 +351,7 @@ private class CacheDatabase(context: Context) :
|
||||||
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
|
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
|
||||||
put(Columns.ALBUM_NAME, rawSong.albumName)
|
put(Columns.ALBUM_NAME, rawSong.albumName)
|
||||||
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
|
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_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue())
|
||||||
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
|
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
|
||||||
|
|
@ -422,8 +422,8 @@ private class CacheDatabase(context: Context) :
|
||||||
const val ALBUM_NAME = "album"
|
const val ALBUM_NAME = "album"
|
||||||
/** @see Song.Raw.albumSortName */
|
/** @see Song.Raw.albumSortName */
|
||||||
const val ALBUM_SORT_NAME = "album_sort"
|
const val ALBUM_SORT_NAME = "album_sort"
|
||||||
/** @see Song.Raw.albumTypes */
|
/** @see Song.Raw.releaseTypes */
|
||||||
const val ALBUM_TYPES = "album_types"
|
const val RELEASE_TYPES = "album_types"
|
||||||
/** @see Song.Raw.artistMusicBrainzIds */
|
/** @see Song.Raw.artistMusicBrainzIds */
|
||||||
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
|
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
|
||||||
/** @see Song.Raw.artistNames */
|
/** @see Song.Raw.artistNames */
|
||||||
|
|
@ -442,7 +442,7 @@ private class CacheDatabase(context: Context) :
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DB_NAME = "auxio_music_cache.db"
|
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"
|
private const val TABLE_RAW_SONGS = "raw_songs"
|
||||||
|
|
||||||
@Volatile private var INSTANCE: CacheDatabase? = null
|
@Volatile private var INSTANCE: CacheDatabase? = null
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,17 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import java.io.File
|
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.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.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.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
@ -86,20 +86,20 @@ abstract class MediaStoreExtractor(
|
||||||
open fun init(): Cursor {
|
open fun init(): Cursor {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
cacheExtractor.init()
|
cacheExtractor.init()
|
||||||
val settings = Settings(context)
|
val musicSettings = MusicSettings.from(context)
|
||||||
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||||
|
|
||||||
val args = mutableListOf<String>()
|
val args = mutableListOf<String>()
|
||||||
var selector = BASE_SELECTOR
|
var selector = BASE_SELECTOR
|
||||||
|
|
||||||
// Filter out audio that is not music, if enabled.
|
// Filter out audio that is not music, if enabled.
|
||||||
if (settings.excludeNonMusic) {
|
if (musicSettings.excludeNonMusic) {
|
||||||
logD("Excluding non-music")
|
logD("Excluding non-music")
|
||||||
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the projection to follow the music directory configuration.
|
// Set up the projection to follow the music directory configuration.
|
||||||
val dirs = settings.getMusicDirs(storageManager)
|
val dirs = musicSettings.musicDirs
|
||||||
if (dirs.dirs.isNotEmpty()) {
|
if (dirs.dirs.isNotEmpty()) {
|
||||||
selector += " AND "
|
selector += " AND "
|
||||||
if (!dirs.shouldInclude) {
|
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
|
// 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.
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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 androidx.core.text.isDigitsOnly
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
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.Song
|
||||||
import org.oxycblt.auxio.music.filesystem.toAudioUri
|
|
||||||
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
|
@ -61,12 +62,11 @@ class MetadataExtractor(
|
||||||
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
|
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
|
* Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will
|
||||||
* sub-extractors before parsing the metadata itself.
|
* 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
|
* @return A flow of [Song.Raw] instances.
|
||||||
* successfully loaded.
|
|
||||||
*/
|
*/
|
||||||
suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
|
fun extract() = flow {
|
||||||
while (true) {
|
while (true) {
|
||||||
val raw = Song.Raw()
|
val raw = Song.Raw()
|
||||||
when (mediaStoreExtractor.populate(raw)) {
|
when (mediaStoreExtractor.populate(raw)) {
|
||||||
|
|
@ -160,9 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
|
|
||||||
val metadata = format.metadata
|
val metadata = format.metadata
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
val tags = Tags(metadata)
|
val textTags = TextTags(metadata)
|
||||||
populateWithId3v2(tags.id3v2)
|
populateWithId3v2(textTags.id3v2)
|
||||||
populateWithVorbis(tags.vorbis)
|
populateWithVorbis(textTags.vorbis)
|
||||||
} else {
|
} else {
|
||||||
logD("No metadata could be extracted for ${raw.name}")
|
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["TALB"]?.let { raw.albumName = it[0] }
|
||||||
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
|
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||||
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
|
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
|
||||||
raw.albumTypes = it
|
raw.releaseTypes = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
|
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
|
||||||
textFrames["TPE1"]?.let { raw.artistNames = it }
|
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it }
|
||||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||||
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
|
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it }
|
||||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
|
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
|
||||||
|
raw.albumArtistSortNames = it
|
||||||
|
}
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
textFrames["TCON"]?.let { raw.genreNames = it }
|
textFrames["TCON"]?.let { raw.genreNames = it }
|
||||||
|
|
@ -229,7 +231,7 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
* Frames.
|
* Frames.
|
||||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||||
* values.
|
* 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
|
* 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.
|
* 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!)
|
// date tag that android supports, so it must be 15 years old or more!)
|
||||||
(comments["originaldate"]?.run { Date.from(first()) }
|
(comments["originaldate"]?.run { Date.from(first()) }
|
||||||
?: comments["date"]?.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 }
|
?.let { raw.date = it }
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||||
comments["album"]?.let { raw.albumName = it[0] }
|
comments["album"]?.let { raw.albumName = it[0] }
|
||||||
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
||||||
comments["releasetype"]?.let { raw.albumTypes = it }
|
comments["releasetype"]?.let { raw.releaseTypes = it }
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
||||||
comments["artist"]?.let { raw.artistNames = it }
|
(comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it }
|
||||||
comments["artistsort"]?.let { raw.artistSortNames = it }
|
(comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||||
comments["albumartist"]?.let { raw.albumArtistNames = it }
|
(comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it }
|
||||||
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
|
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
|
||||||
|
raw.albumArtistSortNames = it
|
||||||
|
}
|
||||||
|
|
||||||
// Genre
|
// 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
|
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.
|
* @param metadata The [Metadata] to wrap.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Tags(metadata: Metadata) {
|
class TextTags(metadata: Metadata) {
|
||||||
private val _id3v2 = mutableMapOf<String, List<String>>()
|
private val _id3v2 = mutableMapOf<String, List<String>>()
|
||||||
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
||||||
val id3v2: Map<String, List<String>>
|
val id3v2: Map<String, List<String>>
|
||||||
|
|
@ -65,6 +65,10 @@ class Tags(metadata: Metadata) {
|
||||||
is VorbisComment -> {
|
is VorbisComment -> {
|
||||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||||
val id = tag.key.sanitize().lowercase()
|
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()
|
val value = tag.value.sanitize().correctWhitespace()
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
|
_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/>.
|
* 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 androidx.annotation.IdRes
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
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.
|
* 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.
|
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
|
||||||
* @param songs The [Song]s to sort.
|
* @param songs The [Song]s to sort.
|
||||||
*/
|
*/
|
||||||
fun songsInPlace(songs: MutableList<Song>) {
|
private fun songsInPlace(songs: MutableList<Song>) {
|
||||||
songs.sortWith(mode.getSongComparator(isAscending))
|
songs.sortWith(mode.getSongComparator(isAscending))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.parsing
|
package org.oxycblt.auxio.music.parsing
|
||||||
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
/// --- GENERIC PARSING ---
|
/// --- 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
|
* 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
|
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
||||||
* user's separator preferences.
|
* 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.
|
* @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) {
|
if (size == 1) {
|
||||||
first().maybeParseBySeparators(settings)
|
first().maybeParseBySeparators(settings)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -99,10 +99,9 @@ fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
|
||||||
* @param settings [Settings] required to obtain user separator configuration.
|
* @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.
|
* @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.
|
// Get the separators the user desires. If null, there's nothing to do.
|
||||||
val separators = settings.musicSeparators ?: return listOf(this)
|
return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
|
||||||
return splitEscaped { separators.contains(it) }.correctWhitespace()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// --- ID3v2 PARSING ---
|
/// --- 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
|
* 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
|
* representations of genre fields into their named counterparts, and split up singular ID3v2-style
|
||||||
* integer genre fields into one or more genres.
|
* 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..
|
* @return A list of one or more genre names..
|
||||||
*/
|
*/
|
||||||
fun List<String>.parseId3GenreNames(settings: Settings) =
|
fun List<String>.parseId3GenreNames(settings: MusicSettings) =
|
||||||
if (size == 1) {
|
if (size == 1) {
|
||||||
first().parseId3MultiValueGenre(settings)
|
first().parseId3MultiValueGenre(settings)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,9 +131,10 @@ fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
|
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
|
||||||
|
* @param settings [MusicSettings] required to obtain user separator configuration.
|
||||||
* @return A list of one or more genre names.
|
* @return 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)
|
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.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -42,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||||
.setTitle(R.string.set_separators)
|
.setTitle(R.string.set_separators)
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
.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
|
// the corresponding CheckBox for each character instead of doing an iteration
|
||||||
// through the separator list for each CheckBox.
|
// through the separator list for each CheckBox.
|
||||||
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
|
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
|
||||||
?: Settings(requireContext()).musicSeparators)
|
?: MusicSettings.from(requireContext()).multiValueSeparators)
|
||||||
?.forEach {
|
.forEach {
|
||||||
when (it) {
|
when (it) {
|
||||||
Separators.COMMA -> binding.separatorComma.isChecked = true
|
Separators.COMMA -> binding.separatorComma.isChecked = true
|
||||||
Separators.SEMICOLON -> binding.separatorSemicolon.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.
|
* @param listener A [ClickableListListener] to bind interactions to.
|
||||||
* @author OxygenCobalt.
|
* @author OxygenCobalt.
|
||||||
*/
|
*/
|
||||||
class ArtistChoiceAdapter(private val listener: ClickableListListener) :
|
class ArtistChoiceAdapter(private val listener: ClickableListListener<Artist>) :
|
||||||
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
|
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
|
||||||
private var artists = listOf<Artist>()
|
private var artists = listOf<Artist>()
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
* @param artist The new [Artist] to bind.
|
* @param artist The new [Artist] to bind.
|
||||||
* @param listener A [ClickableListListener] to bind interactions to.
|
* @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)
|
listener.bind(artist, this)
|
||||||
binding.pickerImage.bind(artist)
|
binding.pickerImage.bind(artist)
|
||||||
binding.pickerName.text = artist.resolveName(binding.context)
|
binding.pickerName.text = artist.resolveName(binding.context)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
|
|
||||||
|
|
@ -41,9 +40,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||||
super.onClick(item, viewHolder)
|
super.onClick(item, viewHolder)
|
||||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
// User made a choice, navigate to it.
|
// User made a choice, navigate to it.
|
||||||
navModel.exploreNavigateTo(item)
|
navModel.exploreNavigateTo(item)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
@ -38,7 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
abstract class ArtistPickerDialog :
|
abstract class ArtistPickerDialog :
|
||||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Artist> {
|
||||||
protected val pickerModel: PickerViewModel by viewModels()
|
protected val pickerModel: PickerViewModel by viewModels()
|
||||||
// Okay to leak this since the Listener will not be called until after initialization.
|
// Okay to leak this since the Listener will not be called until after initialization.
|
||||||
private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this)
|
private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this)
|
||||||
|
|
@ -68,7 +67,7 @@ abstract class ArtistPickerDialog :
|
||||||
binding.pickerRecycler.adapter = null
|
binding.pickerRecycler.adapter = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,12 @@ import android.os.Bundle
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
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.
|
* An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous.
|
||||||
|
|
@ -42,12 +43,10 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
|
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||||
super.onClick(item, viewHolder)
|
super.onClick(item, viewHolder)
|
||||||
// User made a choice, play the given song from that artist.
|
// User made a choice, play the given song from that artist.
|
||||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
val song = requireIs<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
|
||||||
val song = pickerModel.currentItem.value
|
|
||||||
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
playbackModel.playFromArtist(song, item)
|
playbackModel.playFromArtist(song, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater
|
||||||
* @param listener A [ClickableListListener] to bind interactions to.
|
* @param listener A [ClickableListListener] to bind interactions to.
|
||||||
* @author OxygenCobalt.
|
* @author OxygenCobalt.
|
||||||
*/
|
*/
|
||||||
class GenreChoiceAdapter(private val listener: ClickableListListener) :
|
class GenreChoiceAdapter(private val listener: ClickableListListener<Genre>) :
|
||||||
RecyclerView.Adapter<GenreChoiceViewHolder>() {
|
RecyclerView.Adapter<GenreChoiceViewHolder>() {
|
||||||
private var genres = listOf<Genre>()
|
private var genres = listOf<Genre>()
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||||
* @param genre The new [Genre] to bind.
|
* @param genre The new [Genre] to bind.
|
||||||
* @param listener A [ClickableListListener] to bind interactions to.
|
* @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)
|
listener.bind(genre, this)
|
||||||
binding.pickerImage.bind(genre)
|
binding.pickerImage.bind(genre)
|
||||||
binding.pickerName.text = genre.resolveName(binding.context)
|
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.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class GenrePlaybackPickerDialog :
|
class GenrePlaybackPickerDialog :
|
||||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Genre> {
|
||||||
private val pickerModel: PickerViewModel by viewModels()
|
private val pickerModel: PickerViewModel by viewModels()
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
// Information about what Song to show choices for is initially within the navigation arguments
|
// Information about what Song to show choices for is initially within the navigation arguments
|
||||||
|
|
@ -75,11 +76,9 @@ class GenrePlaybackPickerDialog :
|
||||||
binding.pickerRecycler.adapter = null
|
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.
|
// User made a choice, play the given song from that genre.
|
||||||
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
|
val song = requireIs<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
|
||||||
val song = pickerModel.currentItem.value
|
|
||||||
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
|
||||||
playbackModel.playFromGenre(song, item)
|
playbackModel.playFromGenre(song, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,7 +52,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener {
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
refreshChoices()
|
refreshChoices()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.content.Context
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
|
|
@ -129,7 +129,6 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
|
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
|
||||||
// TODO: Unify include + exclude
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A mime type of a file. Only intended for display.
|
* 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/>.
|
* 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.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
|
@ -25,11 +26,12 @@ import android.view.LayoutInflater
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
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.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
@ -49,20 +51,15 @@ class MusicDirsDialog :
|
||||||
DialogMusicDirsBinding.inflate(inflater)
|
DialogMusicDirsBinding.inflate(inflater)
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||||
// Don't set the click listener here, we do some custom magic in onCreateView instead.
|
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.set_dirs)
|
.setTitle(R.string.set_dirs)
|
||||||
.setNeutralButton(R.string.lbl_add, null)
|
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
||||||
val settings = Settings(requireContext())
|
val settings = MusicSettings.from(requireContext())
|
||||||
val dirs =
|
|
||||||
settings.getMusicDirs(
|
|
||||||
requireNotNull(storageManager) { "StorageManager was not available" })
|
|
||||||
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
||||||
if (dirs != newDirs) {
|
if (settings.musicDirs != newDirs) {
|
||||||
logD("Committing changes")
|
logD("Committing changes")
|
||||||
settings.setMusicDirs(newDirs)
|
settings.musicDirs = newDirs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,18 +73,21 @@ class MusicDirsDialog :
|
||||||
registerForActivityResult(
|
registerForActivityResult(
|
||||||
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
||||||
|
|
||||||
// Now that the dialog exists, we get the view manually when the dialog is shown
|
binding.dirsAdd.apply {
|
||||||
// and override its click listener so that the dialog does not auto-dismiss when we
|
ViewCompat.setTooltipText(this, contentDescription)
|
||||||
// click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former
|
setOnClickListener {
|
||||||
// and the app from crashing in the latter.
|
|
||||||
requireDialog().setOnShowListener {
|
|
||||||
val dialog = it as AlertDialog
|
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
|
|
||||||
logD("Opening launcher")
|
logD("Opening launcher")
|
||||||
requireNotNull(openDocumentTreeLauncher) {
|
val launcher =
|
||||||
|
requireNotNull(openDocumentTreeLauncher) {
|
||||||
"Document tree launcher was not available"
|
"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
|
itemAnimator = null
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirs = Settings(context).getMusicDirs(storageManager)
|
var dirs = MusicSettings.from(context).musicDirs
|
||||||
|
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
||||||
if (pendingDirs != null) {
|
if (pendingDirs != null) {
|
||||||
|
|
@ -178,8 +177,12 @@ class MusicDirsDialog :
|
||||||
private fun updateMode() {
|
private fun updateMode() {
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
if (isUiModeInclude(binding)) {
|
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)
|
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
|
||||||
} else {
|
} else {
|
||||||
|
binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24)
|
||||||
|
binding.dirsModeInclude.icon = null
|
||||||
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
|
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/>.
|
* 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.annotation.SuppressLint
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
|
|
@ -196,7 +196,7 @@ val StorageVolume.isInternalCompat: Boolean
|
||||||
get() = isPrimaryCompat && isEmulatedCompat
|
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.
|
* null.
|
||||||
* @see StorageVolume.getUuid
|
* @see StorageVolume.getUuid
|
||||||
*/
|
*/
|
||||||
|
|
@ -27,15 +27,9 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
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.extractor.*
|
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.logD
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
@ -51,7 +45,7 @@ import org.oxycblt.auxio.util.logW
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Indexer private constructor() {
|
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 indexingState: Indexing? = null
|
||||||
@Volatile private var controller: Controller? = null
|
@Volatile private var controller: Controller? = null
|
||||||
@Volatile private var listener: Listener? = null
|
@Volatile private var listener: Listener? = null
|
||||||
|
|
@ -197,11 +191,11 @@ class Indexer private constructor() {
|
||||||
* @param context [Context] required to load music.
|
* @param context [Context] required to load music.
|
||||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
* @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.
|
* 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 NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
|
||||||
* @throws NoMusicException If no music was found on the device.
|
* @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) ==
|
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||||
PackageManager.PERMISSION_DENIED) {
|
PackageManager.PERMISSION_DENIED) {
|
||||||
// No permissions, signal that we can't do anything.
|
// No permissions, signal that we can't do anything.
|
||||||
|
|
@ -217,7 +211,6 @@ class Indexer private constructor() {
|
||||||
} else {
|
} else {
|
||||||
WriteOnlyCacheExtractor(context)
|
WriteOnlyCacheExtractor(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaStoreExtractor =
|
val mediaStoreExtractor =
|
||||||
when {
|
when {
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
||||||
|
|
@ -226,33 +219,24 @@ class Indexer private constructor() {
|
||||||
Api29MediaStoreExtractor(context, cacheDatabase)
|
Api29MediaStoreExtractor(context, cacheDatabase)
|
||||||
else -> Api21MediaStoreExtractor(context, cacheDatabase)
|
else -> Api21MediaStoreExtractor(context, cacheDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
||||||
|
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
|
||||||
val songs =
|
|
||||||
buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
|
|
||||||
// Build the rest of the music library from the song list. This is much more powerful
|
// 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.
|
// and reliable compared to using MediaStore to obtain grouping information.
|
||||||
val buildStart = System.currentTimeMillis()
|
val buildStart = System.currentTimeMillis()
|
||||||
val albums = buildAlbums(songs)
|
val library = Library(rawSongs, MusicSettings.from(context))
|
||||||
val artists = buildArtists(songs, albums)
|
|
||||||
val genres = buildGenres(songs)
|
|
||||||
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
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.
|
* Load a list of [Song]s from the device.
|
||||||
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
|
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
|
||||||
* instances.
|
* 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
|
* @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.
|
* with parent [Album], [Artist], and [Genre] items in order to be usable.
|
||||||
*/
|
*/
|
||||||
private suspend fun buildSongs(
|
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
|
||||||
metadataExtractor: MetadataExtractor,
|
|
||||||
settings: Settings
|
|
||||||
): List<Song> {
|
|
||||||
logD("Starting indexing process")
|
logD("Starting indexing process")
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
||||||
|
|
@ -262,104 +246,23 @@ class Indexer private constructor() {
|
||||||
yield()
|
yield()
|
||||||
|
|
||||||
// Note: We use a set here so we can eliminate song duplicates.
|
// Note: We use a set here so we can eliminate song duplicates.
|
||||||
val songs = mutableSetOf<Song>()
|
|
||||||
val rawSongs = mutableListOf<Song.Raw>()
|
val rawSongs = mutableListOf<Song.Raw>()
|
||||||
metadataExtractor.parse { rawSong ->
|
metadataExtractor.extract().collect { rawSong ->
|
||||||
songs.add(Song(rawSong, settings))
|
|
||||||
rawSongs.add(rawSong)
|
rawSongs.add(rawSong)
|
||||||
|
|
||||||
// Now we can signal a defined progress by showing how many songs we have
|
// 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
|
// loaded, and the projected amount of songs we found in the library
|
||||||
// (obtained by the extractors)
|
// (obtained by the extractors)
|
||||||
yield()
|
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
|
// Finalize the extractors with the songs we have now loaded. There is no ETA
|
||||||
// on this process, so go back to an indeterminate state.
|
// on this process, so go back to an indeterminate state.
|
||||||
emitIndexing(Indexing.Indeterminate)
|
emitIndexing(Indexing.Indeterminate)
|
||||||
metadataExtractor.finalize(rawSongs)
|
metadataExtractor.finalize(rawSongs)
|
||||||
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
logD(
|
||||||
|
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
|
||||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
return rawSongs
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -386,7 +289,7 @@ class Indexer private constructor() {
|
||||||
* @param result The new [Result] to emit, representing the outcome of the music loading
|
* @param result The new [Result] to emit, representing the outcome of the music loading
|
||||||
* process.
|
* process.
|
||||||
*/
|
*/
|
||||||
private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
|
private suspend fun emitCompletion(result: Result<Library>) {
|
||||||
yield()
|
yield()
|
||||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
// 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.
|
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||||
|
|
@ -417,7 +320,7 @@ class Indexer private constructor() {
|
||||||
* Music loading has completed.
|
* Music loading has completed.
|
||||||
* @param result The outcome of the music loading process.
|
* @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.
|
* 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
|
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
|
||||||
* the [MusicStore.Library].
|
* the [Library].
|
||||||
*/
|
*/
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.system
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
|
@ -32,12 +31,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
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.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.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
@ -55,8 +53,7 @@ import org.oxycblt.auxio.util.logD
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class IndexerService :
|
class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
private val indexer = Indexer.getInstance()
|
private val indexer = Indexer.getInstance()
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
|
|
@ -68,7 +65,7 @@ class IndexerService :
|
||||||
private lateinit var observingNotification: ObservingNotification
|
private lateinit var observingNotification: ObservingNotification
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
private lateinit var indexerContentObserver: SystemContentObserver
|
private lateinit var indexerContentObserver: SystemContentObserver
|
||||||
private lateinit var settings: Settings
|
private lateinit var settings: MusicSettings
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
@ -83,8 +80,8 @@ class IndexerService :
|
||||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
// 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.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
indexerContentObserver = SystemContentObserver()
|
indexerContentObserver = SystemContentObserver()
|
||||||
settings = Settings(this)
|
settings = MusicSettings.from(this)
|
||||||
settings.addListener(this)
|
settings.registerListener(this)
|
||||||
indexer.registerController(this)
|
indexer.registerController(this)
|
||||||
// An indeterminate indexer and a missing library implies we are extremely early
|
// An indeterminate indexer and a missing library implies we are extremely early
|
||||||
// in app initialization so start loading music.
|
// in app initialization so start loading music.
|
||||||
|
|
@ -108,7 +105,7 @@ class IndexerService :
|
||||||
// Then cancel the listener-dependent components to ensure that stray reloading
|
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||||
// events will not occur.
|
// events will not occur.
|
||||||
indexerContentObserver.release()
|
indexerContentObserver.release()
|
||||||
settings.removeListener(this)
|
settings.unregisterListener(this)
|
||||||
indexer.unregisterController(this)
|
indexer.unregisterController(this)
|
||||||
// Then cancel any remaining music loading jobs.
|
// Then cancel any remaining music loading jobs.
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
|
|
@ -230,22 +227,18 @@ class IndexerService :
|
||||||
|
|
||||||
// --- SETTING CALLBACKS ---
|
// --- SETTING CALLBACKS ---
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onIndexingSettingChanged() {
|
||||||
when (key) {
|
// Music loading configuration changed, need to reload music.
|
||||||
// Hook changes in music settings to a new music loading event.
|
onStartIndexing(true)
|
||||||
getString(R.string.set_key_exclude_non_music),
|
}
|
||||||
getString(R.string.set_key_music_dirs),
|
|
||||||
getString(R.string.set_key_music_dirs_include),
|
override fun onObservingChanged() {
|
||||||
getString(R.string.set_key_separators) -> onStartIndexing(true)
|
// Make sure we don't override the service state with the observing
|
||||||
getString(R.string.set_key_observing) -> {
|
// notification if we were actively loading when the automatic rescanning
|
||||||
// Make sure we don't override the service state with the observing
|
// setting changed. In such a case, the state will still be updated when
|
||||||
// notification if we were actively loading when the automatic rescanning
|
// the music loading process ends.
|
||||||
// setting changed. In such a case, the state will still be updated when
|
if (!indexer.isIndexing) {
|
||||||
// the music loading process ends.
|
updateIdleSession()
|
||||||
if (!indexer.isIndexing) {
|
|
||||||
updateIdleSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 android.content.Context
|
||||||
import java.text.ParseException
|
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 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 {
|
override fun compareTo(other: Date): Int {
|
||||||
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
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)
|
min.resolveDate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
|
||||||
other is Range && min == other.min && max == other.max
|
|
||||||
|
|
||||||
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
|
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 =
|
private val ISO8601_REGEX =
|
||||||
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.
|
* Create a [Date] from a year component.
|
||||||
* @param year The year component.
|
* @param year The year component.
|
||||||
* @return A new [Date] of the given component, or null if the component is invalid.
|
* @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.
|
* 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? {
|
fun from(timestamp: String): Date? {
|
||||||
val tokens =
|
val tokens =
|
||||||
// Match the input with the timestamp regex
|
// Match the input with the timestamp regex. If there is no match, see if we can
|
||||||
(ISO8601_REGEX.matchEntire(timestamp) ?: return null)
|
// fall back to some kind of year value.
|
||||||
|
(ISO8601_REGEX.matchEntire(timestamp)
|
||||||
|
?: return timestamp.toIntOrNull()?.let(Companion::from))
|
||||||
.groupValues
|
.groupValues
|
||||||
// Filter to the specific tokens we want and convert them to integer tokens.
|
// Filter to the specific tokens we want and convert them to integer tokens.
|
||||||
.mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
|
.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? {
|
private fun fromTokens(tokens: List<Int>): Date? {
|
||||||
val validated = mutableListOf<Int>()
|
val validated = mutableListOf<Int>()
|
||||||
validateTokens(tokens, validated)
|
transformTokens(tokens, validated)
|
||||||
if (validated.isEmpty()) {
|
if (validated.isEmpty()) {
|
||||||
// No token was valid, return null.
|
// No token was valid, return null.
|
||||||
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 src The input tokens to validate.
|
||||||
* @param dst The destination list to add valid tokens to.
|
* @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(0)?.nonZeroOrNull() ?: return)
|
||||||
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
|
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
|
||||||
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: 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.databinding.FragmentPlaybackBarBinding
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
|
|
@ -65,8 +64,8 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
binding.playbackInfo.isSelected = true
|
binding.playbackInfo.isSelected = true
|
||||||
|
|
||||||
// Set up actions
|
// Set up actions
|
||||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() }
|
||||||
setupSecondaryActions(binding, Settings(context))
|
setupSecondaryActions(binding, playbackModel.currentBarAction)
|
||||||
|
|
||||||
// Load the track color in manually as it's unclear whether the track actually supports
|
// Load the track color in manually as it's unclear whether the track actually supports
|
||||||
// using a ColorStateList in the resources.
|
// using a ColorStateList in the resources.
|
||||||
|
|
@ -86,8 +85,8 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
binding.playbackInfo.isSelected = false
|
binding.playbackInfo.isSelected = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
|
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
|
||||||
when (settings.playbackBarAction) {
|
when (actionMode) {
|
||||||
ActionMode.NEXT -> {
|
ActionMode.NEXT -> {
|
||||||
binding.playbackSecondaryAction.apply {
|
binding.playbackSecondaryAction.apply {
|
||||||
setIconResource(R.drawable.ic_skip_next_24)
|
setIconResource(R.drawable.ic_skip_next_24)
|
||||||
|
|
@ -109,7 +108,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||||
setIconResource(R.drawable.sel_shuffle_state_24)
|
setIconResource(R.drawable.sel_shuffle_state_24)
|
||||||
contentDescription = getString(R.string.desc_shuffle)
|
contentDescription = getString(R.string.desc_shuffle)
|
||||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||||
setOnClickListener { playbackModel.invertShuffled() }
|
setOnClickListener { playbackModel.toggleShuffled() }
|
||||||
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
|
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,9 @@ class PlaybackPanelFragment :
|
||||||
// TODO: Add better playback button accessibility
|
// TODO: Add better playback button accessibility
|
||||||
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() }
|
||||||
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
|
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
|
||||||
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
|
binding.playbackShuffle.setOnClickListener { playbackModel.toggleShuffled() }
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP --
|
// --- VIEWMODEL SETUP --
|
||||||
collectImmediately(playbackModel.song, ::updateSong)
|
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.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
import org.oxycblt.auxio.playback.state.*
|
||||||
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.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,8 +35,10 @@ import org.oxycblt.auxio.util.context
|
||||||
*/
|
*/
|
||||||
class PlaybackViewModel(application: Application) :
|
class PlaybackViewModel(application: Application) :
|
||||||
AndroidViewModel(application), PlaybackStateManager.Listener {
|
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 playbackManager = PlaybackStateManager.getInstance()
|
||||||
|
private val musicStore = MusicStore.getInstance()
|
||||||
private var lastPositionJob: Job? = null
|
private var lastPositionJob: Job? = null
|
||||||
|
|
||||||
private val _song = MutableStateFlow<Song?>(null)
|
private val _song = MutableStateFlow<Song?>(null)
|
||||||
|
|
@ -85,6 +83,10 @@ class PlaybackViewModel(application: Application) :
|
||||||
val genrePickerSong: StateFlow<Song?>
|
val genrePickerSong: StateFlow<Song?>
|
||||||
get() = _genrePlaybackPickerSong
|
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
|
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
|
||||||
* available.
|
* available.
|
||||||
|
|
@ -100,13 +102,25 @@ class PlaybackViewModel(application: Application) :
|
||||||
playbackManager.removeListener(this)
|
playbackManager.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexMoved(index: Int) {
|
override fun onIndexMoved(queue: Queue) {
|
||||||
_song.value = playbackManager.song
|
_song.value = queue.currentSong
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
|
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
|
||||||
_song.value = playbackManager.song
|
// Other types of queue changes preserve the current song.
|
||||||
_parent.value = playbackManager.parent
|
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) {
|
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) {
|
override fun onRepeatChanged(repeatMode: RepeatMode) {
|
||||||
_repeatMode.value = repeatMode
|
_repeatMode.value = repeatMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PLAYING FUNCTIONS ---
|
// --- 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. */
|
/** Shuffle all songs in the music library. */
|
||||||
fun shuffleAll() {
|
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 song The [Song] to play.
|
||||||
|
* @param playbackMode The [MusicMode] to play from.
|
||||||
*/
|
*/
|
||||||
fun playFromAlbum(song: Song) {
|
fun playFrom(song: Song, playbackMode: MusicMode) {
|
||||||
playbackManager.play(song, song.album, settings)
|
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) {
|
fun playFromArtist(song: Song, artist: Artist? = null) {
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
check(artist in song.artists) { "Artist not in song artists" }
|
playImpl(song, artist)
|
||||||
playbackManager.play(song, artist, settings)
|
|
||||||
} else if (song.artists.size == 1) {
|
} else if (song.artists.size == 1) {
|
||||||
playbackManager.play(song, song.artists[0], settings)
|
playImpl(song, song.artists[0])
|
||||||
} else {
|
} else {
|
||||||
_artistPlaybackPickerSong.value = song
|
_artistPlaybackPickerSong.value = song
|
||||||
}
|
}
|
||||||
|
|
@ -191,61 +202,91 @@ class PlaybackViewModel(application: Application) :
|
||||||
*/
|
*/
|
||||||
fun playFromGenre(song: Song, genre: Genre? = null) {
|
fun playFromGenre(song: Song, genre: Genre? = null) {
|
||||||
if (genre != null) {
|
if (genre != null) {
|
||||||
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
|
playImpl(song, genre)
|
||||||
playbackManager.play(song, genre, settings)
|
|
||||||
} else if (song.genres.size == 1) {
|
} else if (song.genres.size == 1) {
|
||||||
playbackManager.play(song, song.genres[0], settings)
|
playImpl(song, song.genres[0])
|
||||||
} else {
|
} else {
|
||||||
_genrePlaybackPickerSong.value = song
|
_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].
|
* Play an [Album].
|
||||||
* @param album The [Album] to play.
|
* @param album The [Album] to play.
|
||||||
*/
|
*/
|
||||||
fun play(album: Album) {
|
fun play(album: Album) = playImpl(null, album, false)
|
||||||
playbackManager.play(null, album, settings, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play an [Artist].
|
* Play an [Artist].
|
||||||
* @param artist The [Artist] to play.
|
* @param artist The [Artist] to play.
|
||||||
*/
|
*/
|
||||||
fun play(artist: Artist) {
|
fun play(artist: Artist) = playImpl(null, artist, false)
|
||||||
playbackManager.play(null, artist, settings, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play a [Genre].
|
* Play a [Genre].
|
||||||
* @param genre The [Genre] to play.
|
* @param genre The [Genre] to play.
|
||||||
*/
|
*/
|
||||||
fun play(genre: Genre) {
|
fun play(genre: Genre) = playImpl(null, genre, false)
|
||||||
playbackManager.play(null, genre, settings, 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].
|
* Shuffle an [Album].
|
||||||
* @param album The [Album] to shuffle.
|
* @param album The [Album] to shuffle.
|
||||||
*/
|
*/
|
||||||
fun shuffle(album: Album) {
|
fun shuffle(album: Album) = playImpl(null, album, true)
|
||||||
playbackManager.play(null, album, settings, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuffle an [Artist].
|
* Shuffle an [Artist].
|
||||||
* @param artist The [Artist] to shuffle.
|
* @param artist The [Artist] to shuffle.
|
||||||
*/
|
*/
|
||||||
fun shuffle(artist: Artist) {
|
fun shuffle(artist: Artist) = playImpl(null, artist, true)
|
||||||
playbackManager.play(null, artist, settings, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuffle an [Genre].
|
* Shuffle an [Genre].
|
||||||
* @param genre The [Genre] to shuffle.
|
* @param genre The [Genre] to shuffle.
|
||||||
*/
|
*/
|
||||||
fun shuffle(genre: Genre) {
|
fun shuffle(genre: Genre) = playImpl(null, genre, true)
|
||||||
playbackManager.play(null, genre, settings, 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.
|
* @param song The [Song] to add.
|
||||||
*/
|
*/
|
||||||
fun playNext(song: Song) {
|
fun playNext(song: Song) {
|
||||||
// TODO: Queue additions without a playing song should map to playing items
|
|
||||||
// (impossible until queue rework)
|
|
||||||
playbackManager.playNext(song)
|
playbackManager.playNext(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,7 +333,7 @@ class PlaybackViewModel(application: Application) :
|
||||||
* @param album The [Album] to add.
|
* @param album The [Album] to add.
|
||||||
*/
|
*/
|
||||||
fun playNext(album: Album) {
|
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.
|
* @param artist The [Artist] to add.
|
||||||
*/
|
*/
|
||||||
fun playNext(artist: Artist) {
|
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.
|
* @param genre The [Genre] to add.
|
||||||
*/
|
*/
|
||||||
fun playNext(genre: Genre) {
|
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.
|
* @param album The [Album] to add.
|
||||||
*/
|
*/
|
||||||
fun addToQueue(album: Album) {
|
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.
|
* @param artist The [Artist] to add.
|
||||||
*/
|
*/
|
||||||
fun addToQueue(artist: Artist) {
|
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.
|
* @param genre The [Genre] to add.
|
||||||
*/
|
*/
|
||||||
fun addToQueue(genre: Genre) {
|
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 ---
|
// --- STATUS FUNCTIONS ---
|
||||||
|
|
||||||
/** Toggle [isPlaying] (i.e from playing to paused) */
|
/** Toggle [isPlaying] (i.e from playing to paused) */
|
||||||
fun toggleIsPlaying() {
|
fun togglePlaying() {
|
||||||
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle [isShuffled] (ex. from on to off) */
|
/** Toggle [isShuffled] (ex. from on to off) */
|
||||||
fun invertShuffled() {
|
fun toggleShuffled() {
|
||||||
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
|
playbackManager.reorder(!playbackManager.queue.isShuffled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -427,9 +466,9 @@ class PlaybackViewModel(application: Application) :
|
||||||
private fun selectionToSongs(selection: List<Music>): List<Song> {
|
private fun selectionToSongs(selection: List<Music>): List<Song> {
|
||||||
return selection.flatMap {
|
return selection.flatMap {
|
||||||
when (it) {
|
when (it) {
|
||||||
is Album -> settings.detailAlbumSort.songs(it.songs)
|
is Album -> musicSettings.albumSongSort.songs(it.songs)
|
||||||
is Artist -> settings.detailArtistSort.songs(it.songs)
|
is Artist -> musicSettings.artistSongSort.songs(it.songs)
|
||||||
is Genre -> settings.detailGenreSort.songs(it.songs)
|
is Genre -> musicSettings.genreSongSort.songs(it.songs)
|
||||||
is Song -> listOf(it)
|
is Song -> listOf(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,31 +27,28 @@ import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
|
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
|
||||||
import org.oxycblt.auxio.list.EditableListListener
|
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.SongViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.*
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
|
||||||
import org.oxycblt.auxio.util.getDimen
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that shows an editable list of queue items.
|
* A [RecyclerView.Adapter] that shows an editable list of queue items.
|
||||||
* @param listener A [EditableListListener] to bind interactions to.
|
* @param listener A [EditableListListener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueAdapter(private val listener: EditableListListener) :
|
class QueueAdapter(private val listener: EditableListListener<Song>) :
|
||||||
RecyclerView.Adapter<QueueSongViewHolder>() {
|
DiffAdapter<Song, BasicListInstructions, QueueSongViewHolder>(
|
||||||
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
|
ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) {
|
||||||
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
|
// 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
|
// adapter, as one item can appear at several points in the UI. Use a similar implementation
|
||||||
// with an index value instead.
|
// with an index value instead.
|
||||||
private var currentIndex = 0
|
private var currentIndex = 0
|
||||||
private var isPlaying = false
|
private var isPlaying = false
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
QueueSongViewHolder.from(parent)
|
QueueSongViewHolder.from(parent)
|
||||||
|
|
||||||
|
|
@ -64,31 +61,13 @@ class QueueAdapter(private val listener: EditableListListener) :
|
||||||
payload: List<Any>
|
payload: List<Any>
|
||||||
) {
|
) {
|
||||||
if (payload.isEmpty()) {
|
if (payload.isEmpty()) {
|
||||||
viewHolder.bind(differ.currentList[position], listener)
|
viewHolder.bind(getItem(position), listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewHolder.isFuture = position > currentIndex
|
viewHolder.isFuture = position > currentIndex
|
||||||
viewHolder.updatePlayingIndicator(position == currentIndex, isPlaying)
|
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
|
* Set the position of the currently playing item in the queue. This will mark the item as
|
||||||
* playing and any previous items as played.
|
* 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.
|
* @param isPlaying Whether playback is ongoing or paused.
|
||||||
*/
|
*/
|
||||||
fun setPosition(index: Int, isPlaying: Boolean) {
|
fun setPosition(index: Int, isPlaying: Boolean) {
|
||||||
var updatedIndex = false
|
logD("Updating index")
|
||||||
|
val lastIndex = currentIndex
|
||||||
|
currentIndex = index
|
||||||
|
|
||||||
if (index != currentIndex) {
|
// Have to update not only the currently playing item, but also all items marked
|
||||||
val lastIndex = currentIndex
|
// as playing.
|
||||||
currentIndex = index
|
if (currentIndex < lastIndex) {
|
||||||
updatedIndex = true
|
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION)
|
||||||
|
} else {
|
||||||
// Have to update not only the currently playing item, but also all items marked
|
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION)
|
||||||
// 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
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|
@ -158,7 +126,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
||||||
binding.songAlbumCover.isEnabled = value
|
binding.songAlbumCover.isEnabled = value
|
||||||
binding.songName.isEnabled = value
|
binding.songName.isEnabled = value
|
||||||
binding.songInfo.isEnabled = value
|
binding.songInfo.isEnabled = value
|
||||||
binding.songDragHandle.isEnabled = value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -178,7 +145,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
||||||
* @param listener A [EditableListListener] to bind interactions to.
|
* @param listener A [EditableListListener] to bind interactions to.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
fun bind(song: Song, listener: EditableListListener) {
|
fun bind(song: Song, listener: EditableListListener<Song>) {
|
||||||
listener.bind(song, this, bodyView, binding.songDragHandle)
|
listener.bind(song, this, bodyView, binding.songDragHandle)
|
||||||
binding.songAlbumCover.bind(song)
|
binding.songAlbumCover.bind(song)
|
||||||
binding.songName.text = song.resolveName(binding.context)
|
binding.songName.text = song.resolveName(binding.context)
|
||||||
|
|
@ -202,6 +169,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
|
||||||
fun from(parent: View) =
|
fun from(parent: View) =
|
||||||
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
|
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. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
|
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,
|
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in the queue UI,
|
||||||
* such as an animation when lifting items.
|
* such as an animation when lifting items.
|
||||||
|
*
|
||||||
|
* TODO: Why is item movement so expensive???
|
||||||
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
|
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
|
||||||
private var shouldLift = true
|
private var shouldLift = true
|
||||||
|
|
||||||
override fun getMovementFlags(
|
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) =
|
||||||
recyclerView: RecyclerView,
|
makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
|
||||||
viewHolder: RecyclerView.ViewHolder
|
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
|
||||||
): 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 onChildDraw(
|
override fun onChildDraw(
|
||||||
c: Canvas,
|
c: Canvas,
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,18 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
import org.oxycblt.auxio.databinding.FragmentQueueBinding
|
||||||
import org.oxycblt.auxio.list.EditableListListener
|
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.music.Song
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingFragment] that displays an editable queue.
|
* A [ViewBindingFragment] that displays an editable queue.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
|
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
|
||||||
private val queueModel: QueueViewModel by activityViewModels()
|
private val queueModel: QueueViewModel by activityViewModels()
|
||||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||||
private val queueAdapter = QueueAdapter(this)
|
private val queueAdapter = QueueAdapter(this)
|
||||||
|
|
@ -78,10 +77,11 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentQueueBinding) {
|
override fun onDestroyBinding(binding: FragmentQueueBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
|
touchHelper = null
|
||||||
binding.queueRecycler.adapter = 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)
|
queueModel.goto(viewHolder.bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,18 +100,13 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
|
|
||||||
// Replace or diff the queue depending on the type of change it is.
|
// Replace or diff the queue depending on the type of change it is.
|
||||||
// TODO: Extend this to the whole app.
|
val instructions = queueModel.queueListInstructions
|
||||||
if (queueModel.replaceQueue == true) {
|
queueAdapter.submitList(queue, instructions?.update ?: BasicListInstructions.DIFF)
|
||||||
logD("Replacing queue")
|
// Update position in list (and thus past/future items)
|
||||||
queueAdapter.replaceList(queue)
|
queueAdapter.setPosition(index, isPlaying)
|
||||||
} else {
|
|
||||||
logD("Diffing queue")
|
|
||||||
queueAdapter.submitList(queue)
|
|
||||||
}
|
|
||||||
queueModel.finishReplace()
|
|
||||||
|
|
||||||
// If requested, scroll to a new item (occurs when the index moves)
|
// If requested, scroll to a new item (occurs when the index moves)
|
||||||
val scrollTo = queueModel.scrollTo
|
val scrollTo = instructions?.scrollTo
|
||||||
if (scrollTo != null) {
|
if (scrollTo != null) {
|
||||||
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
|
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
|
||||||
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
val start = lmm.findFirstCompletelyVisibleItemPosition()
|
||||||
|
|
@ -126,15 +121,13 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
|
||||||
binding.queueRecycler.scrollToPosition(scrollTo)
|
binding.queueRecycler.scrollToPosition(scrollTo)
|
||||||
} else if (scrollTo > end) {
|
} else if (scrollTo > end) {
|
||||||
// We need to scroll downwards, we need to offset by a screen of songs.
|
// 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
|
// This does have some error due to how many completely visible items on-screen
|
||||||
// somewhat mutable. This is considered okay.
|
// can vary. This is considered okay.
|
||||||
binding.queueRecycler.scrollToPosition(
|
binding.queueRecycler.scrollToPosition(
|
||||||
min(queue.lastIndex, scrollTo + (end - start)))
|
min(queue.lastIndex, scrollTo + (end - start)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
queueModel.finishScrollTo()
|
|
||||||
|
|
||||||
// Update position in list (and thus past/future items)
|
queueModel.finishInstructions()
|
||||||
queueAdapter.setPosition(index, isPlaying)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ package org.oxycblt.auxio.playback.queue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
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.
|
* 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. */
|
/** The current queue. */
|
||||||
val queue: StateFlow<List<Song>> = _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. */
|
/** The index of the currently playing song in the queue. */
|
||||||
val index: StateFlow<Int>
|
val index: StateFlow<Int>
|
||||||
get() = _index
|
get() = _index
|
||||||
|
|
||||||
/** Whether to replace or diff the queue list when updating it. Is null if not specified. */
|
/** Specifies how to update the list when the queue changes. */
|
||||||
var replaceQueue: Boolean? = null
|
var queueListInstructions: ListInstructions? = null
|
||||||
/** Flag to scroll to a particular queue item. Is null if no command has been specified. */
|
|
||||||
var scrollTo: Int? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addListener(this)
|
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.
|
* 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
|
* @param adapterIndex The index of the queue item to play. Does nothing if the index is out of
|
||||||
* range.
|
* range.
|
||||||
*/
|
*/
|
||||||
fun goto(adapterIndex: Int) {
|
fun goto(adapterIndex: Int) {
|
||||||
if (adapterIndex !in playbackManager.queue.indices) {
|
|
||||||
// Invalid input. Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playbackManager.goto(adapterIndex)
|
playbackManager.goto(adapterIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,10 +99,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
|
||||||
* range.
|
* range.
|
||||||
*/
|
*/
|
||||||
fun removeQueueDataItem(adapterIndex: Int) {
|
fun removeQueueDataItem(adapterIndex: Int) {
|
||||||
if (adapterIndex <= playbackManager.index ||
|
if (adapterIndex !in queue.value.indices) {
|
||||||
adapterIndex !in playbackManager.queue.indices) {
|
|
||||||
// Invalid input. Nothing to do.
|
|
||||||
// TODO: Allow editing played queue items.
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
playbackManager.removeQueueItem(adapterIndex)
|
playbackManager.removeQueueItem(adapterIndex)
|
||||||
|
|
@ -85,56 +112,17 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
|
||||||
* @return true if the items were moved, false otherwise.
|
* @return true if the items were moved, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
|
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
|
||||||
if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) {
|
if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) {
|
||||||
// Invalid input. Nothing to do.
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
playbackManager.moveQueueItem(adapterFrom, adapterTo)
|
playbackManager.moveQueueItem(adapterFrom, adapterTo)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finish a replace flag specified by [replaceQueue]. */
|
/** Signal that the specified [ListInstructions] in [queueListInstructions] were performed. */
|
||||||
fun finishReplace() {
|
fun finishInstructions() {
|
||||||
replaceQueue = null
|
queueListInstructions = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finish a scroll operation started by [scrollTo]. */
|
class ListInstructions(val update: BasicListInstructions?, val scrollTo: Int?)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
|
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,11 +39,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
|
||||||
.setTitle(R.string.set_pre_amp)
|
.setTitle(R.string.set_pre_amp)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
Settings(requireContext()).replayGainPreAmp =
|
PlaybackSettings.from(requireContext()).replayGainPreAmp =
|
||||||
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
|
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
|
||||||
}
|
}
|
||||||
.setNeutralButton(R.string.lbl_reset) { _, _ ->
|
.setNeutralButton(R.string.lbl_reset) { _, _ ->
|
||||||
Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
|
PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.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
|
// 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
|
// settings. After this, the sliders save their own state, so we do not need to
|
||||||
// do any restore behavior.
|
// do any restore behavior.
|
||||||
val preAmp = Settings(requireContext()).replayGainPreAmp
|
val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp
|
||||||
binding.withTagsSlider.value = preAmp.with
|
binding.withTagsSlider.value = preAmp.with
|
||||||
binding.withoutTagsSlider.value = preAmp.without
|
binding.withoutTagsSlider.value = preAmp.without
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
package org.oxycblt.auxio.playback.replaygain
|
package org.oxycblt.auxio.playback.replaygain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import com.google.android.exoplayer2.C
|
import com.google.android.exoplayer2.C
|
||||||
import com.google.android.exoplayer2.Format
|
import com.google.android.exoplayer2.Format
|
||||||
import com.google.android.exoplayer2.Player
|
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 com.google.android.exoplayer2.util.MimeTypes
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.music.Album
|
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.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,10 +43,10 @@ import org.oxycblt.auxio.util.logD
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class ReplayGainAudioProcessor(private val context: Context) :
|
class ReplayGainAudioProcessor(context: Context) :
|
||||||
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
|
BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener {
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val settings = Settings(context)
|
private val playbackSettings = PlaybackSettings.from(context)
|
||||||
private var lastFormat: Format? = null
|
private var lastFormat: Format? = null
|
||||||
|
|
||||||
private var volume = 1f
|
private var volume = 1f
|
||||||
|
|
@ -65,7 +63,7 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
*/
|
*/
|
||||||
fun addToListeners(player: Player) {
|
fun addToListeners(player: Player) {
|
||||||
player.addListener(this)
|
player.addListener(this)
|
||||||
settings.addListener(this)
|
playbackSettings.registerListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,7 +73,7 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
*/
|
*/
|
||||||
fun releaseFromListeners(player: Player) {
|
fun releaseFromListeners(player: Player) {
|
||||||
player.removeListener(this)
|
player.removeListener(this)
|
||||||
settings.removeListener(this)
|
playbackSettings.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- OVERRIDES ---
|
// --- OVERRIDES ---
|
||||||
|
|
@ -98,13 +96,9 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
applyReplayGain(null)
|
applyReplayGain(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onReplayGainSettingsChanged() {
|
||||||
if (key == context.getString(R.string.set_key_replay_gain) ||
|
// ReplayGain config changed, we need to set it up again.
|
||||||
key == context.getString(R.string.set_key_pre_amp_with) ||
|
applyReplayGain(lastFormat)
|
||||||
key == context.getString(R.string.set_key_pre_amp_without)) {
|
|
||||||
// ReplayGain changed, we need to set it up again.
|
|
||||||
applyReplayGain(lastFormat)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- REPLAYGAIN PARSING ---
|
// --- REPLAYGAIN PARSING ---
|
||||||
|
|
@ -116,26 +110,24 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
private fun applyReplayGain(format: Format?) {
|
private fun applyReplayGain(format: Format?) {
|
||||||
lastFormat = format
|
lastFormat = format
|
||||||
val gain = parseReplayGain(format ?: return)
|
val gain = parseReplayGain(format ?: return)
|
||||||
val preAmp = settings.replayGainPreAmp
|
val preAmp = playbackSettings.replayGainPreAmp
|
||||||
|
|
||||||
val adjust =
|
val adjust =
|
||||||
if (gain != null) {
|
if (gain != null) {
|
||||||
logD("Found ReplayGain adjustment $gain")
|
logD("Found ReplayGain adjustment $gain")
|
||||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||||
val useAlbumGain =
|
val useAlbumGain =
|
||||||
when (settings.replayGainMode) {
|
when (playbackSettings.replayGainMode) {
|
||||||
// User wants track gain to be preferred. Default to album gain only if
|
// User wants track gain to be preferred. Default to album gain only if
|
||||||
// there is no track gain.
|
// there is no track gain.
|
||||||
ReplayGainMode.TRACK -> gain.track == 0f
|
ReplayGainMode.TRACK -> gain.track == 0f
|
||||||
|
|
||||||
// User wants album gain to be preferred. Default to track gain only if
|
// User wants album gain to be preferred. Default to track gain only if
|
||||||
// here is no album gain.
|
// here is no album gain.
|
||||||
ReplayGainMode.ALBUM -> gain.album != 0f
|
ReplayGainMode.ALBUM -> gain.album != 0f
|
||||||
|
|
||||||
// User wants album gain to be used when in an album, track gain otherwise.
|
// User wants album gain to be used when in an album, track gain otherwise.
|
||||||
ReplayGainMode.DYNAMIC ->
|
ReplayGainMode.DYNAMIC ->
|
||||||
playbackManager.parent is Album &&
|
playbackManager.parent is Album &&
|
||||||
playbackManager.song?.album == playbackManager.parent
|
playbackManager.queue.currentSong?.album == playbackManager.parent
|
||||||
}
|
}
|
||||||
|
|
||||||
val resolvedGain =
|
val resolvedGain =
|
||||||
|
|
@ -168,35 +160,35 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
||||||
*/
|
*/
|
||||||
private fun parseReplayGain(format: Format): Adjustment? {
|
private fun parseReplayGain(format: Format): Adjustment? {
|
||||||
val tags = Tags(format.metadata ?: return null)
|
val textTags = TextTags(format.metadata ?: return null)
|
||||||
var trackGain = 0f
|
var trackGain = 0f
|
||||||
var albumGain = 0f
|
var albumGain = 0f
|
||||||
|
|
||||||
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
||||||
// replaygain_*_gain tag.
|
// replaygain_*_gain tag.
|
||||||
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
||||||
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { trackGain = it }
|
?.let { trackGain = it }
|
||||||
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { albumGain = it }
|
?.let { albumGain = it }
|
||||||
tags.vorbis[TAG_RG_ALBUM_GAIN]
|
textTags.vorbis[TAG_RG_ALBUM_GAIN]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { trackGain = it }
|
?.let { trackGain = it }
|
||||||
tags.vorbis[TAG_RG_TRACK_GAIN]
|
textTags.vorbis[TAG_RG_TRACK_GAIN]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { albumGain = it }
|
?.let { albumGain = it }
|
||||||
} else {
|
} else {
|
||||||
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
// 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
|
// 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
|
// 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.
|
// specific adjustments.
|
||||||
tags.vorbis[TAG_R128_TRACK_GAIN]
|
textTags.vorbis[TAG_R128_TRACK_GAIN]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { trackGain = it / 256f }
|
?.let { trackGain = it / 256f }
|
||||||
tags.vorbis[TAG_R128_ALBUM_GAIN]
|
textTags.vorbis[TAG_R128_ALBUM_GAIN]
|
||||||
?.run { first().parseReplayGainAdjustment() }
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
?.let { albumGain = it / 256f }
|
?.let { albumGain = it / 256f }
|
||||||
}
|
}
|
||||||
|
|
@ -231,27 +223,32 @@ class ReplayGainAudioProcessor(private val context: Context) :
|
||||||
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isActive() = super.isActive() && volume != 1f
|
|
||||||
|
|
||||||
override fun queueInput(inputBuffer: ByteBuffer) {
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
val position = inputBuffer.position()
|
val pos = inputBuffer.position()
|
||||||
val limit = inputBuffer.limit()
|
val limit = inputBuffer.limit()
|
||||||
val size = limit - position
|
val buffer = replaceOutputBuffer(limit - pos)
|
||||||
val buffer = replaceOutputBuffer(size)
|
|
||||||
|
|
||||||
for (i in position until limit step 2) {
|
if (volume == 1f) {
|
||||||
// Ensure we clamp the values to the minimum and maximum values possible
|
// Nothing to adjust, just copy the audio data.
|
||||||
// for the encoding. This prevents issues where samples amplified beyond
|
// isActive is technically a much better way of doing a no-op like this, but since
|
||||||
// 1 << 16 will end up becoming truncated during the conversion to a short,
|
// the adjustment can change during playback I'm largely forced to do this.
|
||||||
// resulting in popping.
|
buffer.put(inputBuffer.slice())
|
||||||
var sample = inputBuffer.getLeShort(i)
|
} else {
|
||||||
sample =
|
for (i in pos until limit step 2) {
|
||||||
(sample * volume)
|
// 16-bit PCM audio, deserialize a little-endian short.
|
||||||
.toInt()
|
var sample = inputBuffer.getLeShort(i)
|
||||||
.coerceAtLeast(Short.MIN_VALUE.toInt())
|
// Ensure we clamp the values to the minimum and maximum values possible
|
||||||
.coerceAtMost(Short.MAX_VALUE.toInt())
|
// for the encoding. This prevents issues where samples amplified beyond
|
||||||
.toShort()
|
// 1 << 16 will end up becoming truncated during the conversion to a short,
|
||||||
buffer.putLeShort(sample)
|
// resulting in popping.
|
||||||
|
sample =
|
||||||
|
(sample * volume)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(Short.MIN_VALUE.toInt())
|
||||||
|
.coerceAtMost(Short.MAX_VALUE.toInt())
|
||||||
|
.toShort()
|
||||||
|
buffer.putLeShort(sample)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputBuffer.position(limit)
|
inputBuffer.position(limit)
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,10 @@ import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
import android.provider.BaseColumns
|
import android.provider.BaseColumns
|
||||||
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.sqlite.transaction
|
import androidx.core.database.sqlite.transaction
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.util.*
|
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.
|
// of the non-queue parts of the state, such as the playback position.
|
||||||
db.createTable(TABLE_STATE) {
|
db.createTable(TABLE_STATE) {
|
||||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
||||||
append("${StateColumns.INDEX} INTEGER NOT NULL,")
|
append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,")
|
||||||
append("${StateColumns.POSITION} LONG NOT NULL,")
|
append("${PlaybackStateColumns.POSITION} LONG NOT NULL,")
|
||||||
append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,")
|
append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,")
|
||||||
append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
|
append("${PlaybackStateColumns.SONG_UID} STRING,")
|
||||||
append("${StateColumns.SONG_UID} STRING,")
|
append("${PlaybackStateColumns.PARENT_UID} STRING")
|
||||||
append("${StateColumns.PARENT_UID} STRING")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.createTable(TABLE_QUEUE) {
|
db.createTable(TABLE_QUEUE_HEAP) {
|
||||||
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
|
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")
|
logD("Nuking database")
|
||||||
db.apply {
|
db.apply {
|
||||||
execSQL("DROP TABLE IF EXISTS $TABLE_STATE")
|
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)
|
onCreate(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,70 +77,85 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a persisted [SavedState] from the database.
|
* 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.
|
* @return A persisted [SavedState], or null if one could not be found.
|
||||||
*/
|
*/
|
||||||
fun read(library: MusicStore.Library): SavedState? {
|
fun read(library: Library): SavedState? {
|
||||||
requireBackgroundThread()
|
requireBackgroundThread()
|
||||||
// Read the saved state and queue. If the state is non-null, that must imply an
|
// Read the saved state and queue. If the state is non-null, that must imply an
|
||||||
// existent, albeit possibly empty, queue.
|
// existent, albeit possibly empty, queue.
|
||||||
val rawState = readRawState() ?: return null
|
val rawState = readRawPlaybackState() ?: return null
|
||||||
val queue = readQueue(library)
|
val rawQueueState = readRawQueueState(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--
|
|
||||||
}
|
|
||||||
// Restore parent item from the music library. If this fails, then the playback mode
|
// Restore parent item from the music library. If this fails, then the playback mode
|
||||||
// reverts to "All Songs", which is considered okay.
|
// reverts to "All Songs", which is considered okay.
|
||||||
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
|
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
|
||||||
return SavedState(
|
return SavedState(
|
||||||
index = actualIndex,
|
|
||||||
parent = parent,
|
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,
|
positionMs = rawState.positionMs,
|
||||||
repeatMode = rawState.repeatMode,
|
repeatMode = rawState.repeatMode)
|
||||||
isShuffled = rawState.isShuffled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readRawState() =
|
private fun readRawPlaybackState() =
|
||||||
readableDatabase.queryAll(TABLE_STATE) { cursor ->
|
readableDatabase.queryAll(TABLE_STATE) { cursor ->
|
||||||
if (!cursor.moveToFirst()) {
|
if (!cursor.moveToFirst()) {
|
||||||
// Empty, nothing to do.
|
// Empty, nothing to do.
|
||||||
return@queryAll null
|
return@queryAll null
|
||||||
}
|
}
|
||||||
|
|
||||||
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
|
val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX)
|
||||||
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
|
val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION)
|
||||||
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
|
val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE)
|
||||||
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
|
val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID)
|
||||||
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
|
val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID)
|
||||||
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
|
RawPlaybackState(
|
||||||
RawState(
|
|
||||||
index = cursor.getInt(indexIndex),
|
index = cursor.getInt(indexIndex),
|
||||||
positionMs = cursor.getLong(posIndex),
|
positionMs = cursor.getLong(posIndex),
|
||||||
repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
|
repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
|
||||||
?: RepeatMode.NONE,
|
?: RepeatMode.NONE,
|
||||||
isShuffled = cursor.getInt(shuffleIndex) == 1,
|
|
||||||
songUid = Music.UID.fromString(cursor.getString(songUidIndex))
|
songUid = Music.UID.fromString(cursor.getString(songUidIndex))
|
||||||
?: return@queryAll null,
|
?: return@queryAll null,
|
||||||
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readQueue(library: MusicStore.Library): List<Song> {
|
private fun readRawQueueState(library: Library): RawQueueState {
|
||||||
val queue = mutableListOf<Song>()
|
val heap = mutableListOf<Song?>()
|
||||||
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
|
readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor ->
|
||||||
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
|
if (cursor.count == 0) {
|
||||||
|
// Empty, nothing to do.
|
||||||
|
return@queryAll
|
||||||
|
}
|
||||||
|
|
||||||
|
val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID)
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue
|
heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find))
|
||||||
val song = library.find<Song>(uid) ?: continue
|
}
|
||||||
queue.add(song)
|
}
|
||||||
|
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 RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull())
|
||||||
return queue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,41 +165,44 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
||||||
fun write(state: SavedState?) {
|
fun write(state: SavedState?) {
|
||||||
requireBackgroundThread()
|
requireBackgroundThread()
|
||||||
// Only bother saving a state if a song is actively playing from one.
|
// 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.
|
// This is not the case with a null state.
|
||||||
if (state != null && state.index in state.queue.indices) {
|
if (state != null) {
|
||||||
// Transform saved state into raw state, which can then be written to the database.
|
// Transform saved state into raw state, which can then be written to the database.
|
||||||
val rawState =
|
val rawPlaybackState =
|
||||||
RawState(
|
RawPlaybackState(
|
||||||
index = state.index,
|
index = state.queueState.index,
|
||||||
positionMs = state.positionMs,
|
positionMs = state.positionMs,
|
||||||
repeatMode = state.repeatMode,
|
repeatMode = state.repeatMode,
|
||||||
isShuffled = state.isShuffled,
|
songUid = state.queueState.songUid,
|
||||||
songUid = state.queue[state.index].uid,
|
|
||||||
parentUid = state.parent?.uid)
|
parentUid = state.parent?.uid)
|
||||||
writeRawState(rawState)
|
writeRawPlaybackState(rawPlaybackState)
|
||||||
writeQueue(state.queue)
|
val rawQueueState =
|
||||||
|
RawQueueState(
|
||||||
|
heap = state.queueState.heap,
|
||||||
|
orderedMapping = state.queueState.orderedMapping,
|
||||||
|
shuffledMapping = state.queueState.shuffledMapping)
|
||||||
|
writeRawQueueState(rawQueueState)
|
||||||
logD("Wrote state")
|
logD("Wrote state")
|
||||||
} else {
|
} else {
|
||||||
writeRawState(null)
|
writeRawPlaybackState(null)
|
||||||
writeQueue(null)
|
writeRawQueueState(null)
|
||||||
logD("Cleared state")
|
logD("Cleared state")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeRawState(rawState: RawState?) {
|
private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) {
|
||||||
writableDatabase.transaction {
|
writableDatabase.transaction {
|
||||||
delete(TABLE_STATE, null, null)
|
delete(TABLE_STATE, null, null)
|
||||||
|
|
||||||
if (rawState != null) {
|
if (rawPlaybackState != null) {
|
||||||
val stateData =
|
val stateData =
|
||||||
ContentValues(7).apply {
|
ContentValues(7).apply {
|
||||||
put(BaseColumns._ID, 0)
|
put(BaseColumns._ID, 0)
|
||||||
put(StateColumns.SONG_UID, rawState.songUid.toString())
|
put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString())
|
||||||
put(StateColumns.POSITION, rawState.positionMs)
|
put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs)
|
||||||
put(StateColumns.PARENT_UID, rawState.parentUid?.toString())
|
put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString())
|
||||||
put(StateColumns.INDEX, rawState.index)
|
put(PlaybackStateColumns.INDEX, rawPlaybackState.index)
|
||||||
put(StateColumns.IS_SHUFFLED, rawState.isShuffled)
|
put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode)
|
||||||
put(StateColumns.REPEAT_MODE, rawState.repeatMode.intCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
insert(TABLE_STATE, null, stateData)
|
insert(TABLE_STATE, null, stateData)
|
||||||
|
|
@ -187,47 +210,54 @@ class PlaybackStateDatabase private constructor(context: Context) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeQueue(queue: List<Song>?) {
|
private fun writeRawQueueState(rawQueueState: RawQueueState?) {
|
||||||
writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
|
writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song ->
|
||||||
ContentValues(2).apply {
|
ContentValues(2).apply {
|
||||||
put(BaseColumns._ID, i)
|
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.
|
* 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
|
* @param parent The [MusicParent] item currently being played from.
|
||||||
* persisted index no longer exists.
|
* @param queueState The [Queue.SavedState]
|
||||||
* @param queue The [Song] queue.
|
|
||||||
* @param parent The [MusicParent] item currently being played from
|
|
||||||
* @param positionMs The current position in the currently played song, in ms
|
* @param positionMs The current position in the currently played song, in ms
|
||||||
* @param repeatMode The current [RepeatMode].
|
* @param repeatMode The current [RepeatMode].
|
||||||
* @param isShuffled Whether the queue is shuffled or not.
|
|
||||||
*/
|
*/
|
||||||
data class SavedState(
|
data class SavedState(
|
||||||
val index: Int,
|
|
||||||
val queue: List<Song>,
|
|
||||||
val parent: MusicParent?,
|
val parent: MusicParent?,
|
||||||
|
val queueState: Queue.SavedState,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val repeatMode: RepeatMode,
|
val repeatMode: RepeatMode,
|
||||||
val isShuffled: Boolean
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** A lower-level form of [SavedState] that contains individual field-based information. */
|
||||||
* A lower-level form of [SavedState] that contains additional information to create a more
|
private data class RawPlaybackState(
|
||||||
* reliable restoration process.
|
/** @see Queue.SavedState.index */
|
||||||
*/
|
|
||||||
private data class RawState(
|
|
||||||
/** @see SavedState.index */
|
|
||||||
val index: Int,
|
val index: Int,
|
||||||
/** @see SavedState.positionMs */
|
/** @see SavedState.positionMs */
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
/** @see SavedState.repeatMode */
|
/** @see SavedState.repeatMode */
|
||||||
val repeatMode: 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
|
* 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.
|
* 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?
|
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. */
|
/** Defines the columns used in the playback state table. */
|
||||||
private object StateColumns {
|
private object PlaybackStateColumns {
|
||||||
/** @see RawState.index */
|
/** @see RawPlaybackState.index */
|
||||||
const val INDEX = "queue_index"
|
const val INDEX = "queue_index"
|
||||||
/** @see RawState.positionMs */
|
/** @see RawPlaybackState.positionMs */
|
||||||
const val POSITION = "position"
|
const val POSITION = "position"
|
||||||
/** @see RawState.isShuffled */
|
/** @see RawPlaybackState.repeatMode */
|
||||||
const val IS_SHUFFLED = "is_shuffling"
|
|
||||||
/** @see RawState.repeatMode */
|
|
||||||
const val REPEAT_MODE = "repeat_mode"
|
const val REPEAT_MODE = "repeat_mode"
|
||||||
/** @see RawState.songUid */
|
/** @see RawPlaybackState.songUid */
|
||||||
const val SONG_UID = "song_uid"
|
const val SONG_UID = "song_uid"
|
||||||
/** @see RawState.parentUid */
|
/** @see RawPlaybackState.parentUid */
|
||||||
const val PARENT_UID = "parent"
|
const val PARENT_UID = "parent"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Defines the columns used in the queue table. */
|
/** Defines the columns used in the queue heap table. */
|
||||||
private object QueueColumns {
|
private object QueueHeapColumns {
|
||||||
/** @see Music.UID */
|
/** @see Music.UID */
|
||||||
const val SONG_UID = "song_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 {
|
companion object {
|
||||||
private const val DB_NAME = "auxio_playback_state.db"
|
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_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
|
@Volatile private var INSTANCE: PlaybackStateDatabase? = null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,17 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.playback.state
|
package org.oxycblt.auxio.playback.state
|
||||||
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
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.playback.state.PlaybackStateManager.Listener
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logE
|
import org.oxycblt.auxio.util.logE
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core playback state controller class.
|
* Core playback state controller class.
|
||||||
|
|
@ -59,22 +55,13 @@ class PlaybackStateManager private constructor() {
|
||||||
@Volatile private var pendingAction: InternalPlayer.Action? = null
|
@Volatile private var pendingAction: InternalPlayer.Action? = null
|
||||||
@Volatile private var isInitialized = false
|
@Volatile private var isInitialized = false
|
||||||
|
|
||||||
/** The currently playing [Song]. Null if nothing is playing. */
|
/** The current [Queue]. */
|
||||||
val song
|
val queue = Queue()
|
||||||
get() = queue.getOrNull(index)
|
|
||||||
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
|
||||||
@Volatile
|
@Volatile
|
||||||
var parent: MusicParent? = null
|
var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing.
|
||||||
private set
|
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. */
|
/** The current [InternalPlayer] state. */
|
||||||
@Volatile
|
@Volatile
|
||||||
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
|
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
|
||||||
|
|
@ -86,13 +73,8 @@ class PlaybackStateManager private constructor() {
|
||||||
field = value
|
field = value
|
||||||
notifyRepeatModeChanged()
|
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
|
* The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable.
|
||||||
* available.
|
|
||||||
*/
|
*/
|
||||||
val currentAudioSessionId: Int?
|
val currentAudioSessionId: Int?
|
||||||
get() = internalPlayer?.audioSessionId
|
get() = internalPlayer?.audioSessionId
|
||||||
|
|
@ -106,9 +88,8 @@ class PlaybackStateManager private constructor() {
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addListener(listener: Listener) {
|
fun addListener(listener: Listener) {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
listener.onNewPlayback(index, queue, parent)
|
listener.onNewPlayback(queue, parent)
|
||||||
listener.onRepeatChanged(repeatMode)
|
listener.onRepeatChanged(repeatMode)
|
||||||
listener.onShuffledChanged(isShuffled)
|
|
||||||
listener.onStateChanged(playerState)
|
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
|
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||||
* the first place.
|
* the first place.
|
||||||
* @see Listener
|
* @see Listener
|
||||||
|
|
@ -135,13 +116,13 @@ class PlaybackStateManager private constructor() {
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
|
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
|
||||||
if (BuildConfig.DEBUG && this.internalPlayer != null) {
|
if (this.internalPlayer != null) {
|
||||||
logW("Internal player is already registered")
|
logW("Internal player is already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
internalPlayer.loadSong(song, playerState.isPlaying)
|
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
|
||||||
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
|
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
|
||||||
// See if there's any action that has been queued.
|
// See if there's any action that has been queued.
|
||||||
requestAction(internalPlayer)
|
requestAction(internalPlayer)
|
||||||
|
|
@ -160,7 +141,7 @@ class PlaybackStateManager private constructor() {
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
|
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
|
||||||
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
|
if (this.internalPlayer !== internalPlayer) {
|
||||||
logW("Given internal player did not match current internal player")
|
logW("Given internal player did not match current internal player")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -173,29 +154,20 @@ class PlaybackStateManager private constructor() {
|
||||||
/**
|
/**
|
||||||
* Start new playback.
|
* Start new playback.
|
||||||
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
|
* @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
|
* @param queue The queue of [Song]s to play from.
|
||||||
* [MusicStore.Library].
|
* @param parent The [MusicParent] to play from, or null if to play from an non-specific
|
||||||
* @param settings [Settings] required to configure the queue.
|
* collection of "All [Song]s".
|
||||||
* @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle"
|
* @param shuffled Whether to shuffle or not.
|
||||||
* configuration.
|
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play(
|
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
|
||||||
song: Song?,
|
|
||||||
parent: MusicParent?,
|
|
||||||
settings: Settings,
|
|
||||||
shuffled: Boolean = settings.keepShuffle && isShuffled
|
|
||||||
) {
|
|
||||||
val internalPlayer = internalPlayer ?: return
|
val internalPlayer = internalPlayer ?: return
|
||||||
val library = musicStore.library ?: return
|
// Set up parent and queue
|
||||||
// Setup parent and queue
|
|
||||||
this.parent = parent
|
this.parent = parent
|
||||||
_queue = (parent?.songs ?: library.songs).toMutableList()
|
this.queue.start(song, queue, shuffled)
|
||||||
orderQueue(settings, shuffled, song)
|
|
||||||
// Notify components of changes
|
// Notify components of changes
|
||||||
notifyNewPlayback()
|
notifyNewPlayback()
|
||||||
notifyShuffledChanged()
|
internalPlayer.loadSong(this.queue.currentSong, true)
|
||||||
internalPlayer.loadSong(this.song, true)
|
|
||||||
// Played something, so we are initialized now
|
// Played something, so we are initialized now
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
}
|
}
|
||||||
|
|
@ -209,13 +181,13 @@ class PlaybackStateManager private constructor() {
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun next() {
|
fun next() {
|
||||||
val internalPlayer = internalPlayer ?: return
|
val internalPlayer = internalPlayer ?: return
|
||||||
// Increment the index, if it cannot be incremented any further, then
|
var play = true
|
||||||
// repeat and pause/resume playback depending on the setting
|
if (!queue.goto(queue.index + 1)) {
|
||||||
if (index < _queue.lastIndex) {
|
queue.goto(0)
|
||||||
gotoImpl(internalPlayer, index + 1, true)
|
play = false
|
||||||
} else {
|
|
||||||
gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL)
|
|
||||||
}
|
}
|
||||||
|
notifyIndexMoved()
|
||||||
|
internalPlayer.loadSong(queue.currentSong, play)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -231,7 +203,11 @@ class PlaybackStateManager private constructor() {
|
||||||
rewind()
|
rewind()
|
||||||
setPlaying(true)
|
setPlaying(true)
|
||||||
} else {
|
} 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
|
@Synchronized
|
||||||
fun goto(index: Int) {
|
fun goto(index: Int) {
|
||||||
val internalPlayer = internalPlayer ?: return
|
val internalPlayer = internalPlayer ?: return
|
||||||
gotoImpl(internalPlayer, index, true)
|
if (queue.goto(index)) {
|
||||||
}
|
notifyIndexMoved()
|
||||||
|
internalPlayer.loadSong(queue.currentSong, true)
|
||||||
private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) {
|
}
|
||||||
index = idx
|
|
||||||
notifyIndexMoved()
|
|
||||||
internalPlayer.loadSong(song, play)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [Song] to the top of the queue.
|
* Add a [Song] to the top of the queue.
|
||||||
* @param song The [Song] to add.
|
* @param song The [Song] to add.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized fun playNext(song: Song) = playNext(listOf(song))
|
||||||
fun playNext(song: Song) {
|
|
||||||
_queue.add(index + 1, song)
|
|
||||||
notifyQueueChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add [Song]s to the top of the queue.
|
* Add [Song]s to the top of the queue.
|
||||||
|
|
@ -267,19 +236,24 @@ class PlaybackStateManager private constructor() {
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun playNext(songs: List<Song>) {
|
fun playNext(songs: List<Song>) {
|
||||||
_queue.addAll(index + 1, songs)
|
val internalPlayer = internalPlayer ?: return
|
||||||
notifyQueueChanged()
|
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.
|
* Add a [Song] to the end of the queue.
|
||||||
* @param song The [Song] to add.
|
* @param song The [Song] to add.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song))
|
||||||
fun addToQueue(song: Song) {
|
|
||||||
_queue.add(song)
|
|
||||||
notifyQueueChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add [Song]s to the end of the queue.
|
* Add [Song]s to the end of the queue.
|
||||||
|
|
@ -287,82 +261,53 @@ class PlaybackStateManager private constructor() {
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun addToQueue(songs: List<Song>) {
|
fun addToQueue(songs: List<Song>) {
|
||||||
_queue.addAll(songs)
|
val internalPlayer = internalPlayer ?: return
|
||||||
notifyQueueChanged()
|
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.
|
* Move a [Song] in the queue.
|
||||||
* @param from The position of the [Song] to move in the queue.
|
* @param src The position of the [Song] to move in the queue.
|
||||||
* @param to The destination position in the queue.
|
* @param dst The destination position in the queue.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun moveQueueItem(from: Int, to: Int) {
|
fun moveQueueItem(src: Int, dst: Int) {
|
||||||
logD("Moving item $from to position $to")
|
logD("Moving item $src to position $dst")
|
||||||
_queue.add(to, _queue.removeAt(from))
|
notifyQueueChanged(queue.move(src, dst))
|
||||||
notifyQueueChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a [Song] from the queue.
|
* 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
|
@Synchronized
|
||||||
fun removeQueueItem(index: Int) {
|
fun removeQueueItem(at: Int) {
|
||||||
logD("Removing item ${_queue[index].rawName}")
|
val internalPlayer = internalPlayer ?: return
|
||||||
_queue.removeAt(index)
|
logD("Removing item at $at")
|
||||||
notifyQueueChanged()
|
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.
|
* (Re)shuffle or (Re)order this instance.
|
||||||
* @param shuffled Whether to shuffle the queue or not.
|
* @param shuffled Whether to shuffle the queue or not.
|
||||||
* @param settings [Settings] required to configure the queue.
|
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun reshuffle(shuffled: Boolean, settings: Settings) {
|
fun reorder(shuffled: Boolean) {
|
||||||
val song = song ?: return
|
queue.reorder(shuffled)
|
||||||
orderQueue(settings, shuffled, song)
|
notifyQueueReordered()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- INTERNAL PLAYER FUNCTIONS ---
|
// --- INTERNAL PLAYER FUNCTIONS ---
|
||||||
|
|
@ -379,7 +324,7 @@ class PlaybackStateManager private constructor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val newState = internalPlayer.getState(song?.durationMs ?: 0)
|
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
|
||||||
if (newState != playerState) {
|
if (newState != playerState) {
|
||||||
playerState = newState
|
playerState = newState
|
||||||
notifyStateChanged()
|
notifyStateChanged()
|
||||||
|
|
@ -443,7 +388,7 @@ class PlaybackStateManager private constructor() {
|
||||||
/**
|
/**
|
||||||
* Restore the previously saved state (if any) and apply it to the playback state.
|
* Restore the previously saved state (if any) and apply it to the playback state.
|
||||||
* @param database The [PlaybackStateDatabase] to load from.
|
* @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.
|
* @return If the state was restored, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
|
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
|
// State could have changed while we were loading, so check if we were initialized
|
||||||
// now before applying the state.
|
// now before applying the state.
|
||||||
if (state != null && (!isInitialized || force)) {
|
if (state != null && (!isInitialized || force)) {
|
||||||
index = state.index
|
|
||||||
parent = state.parent
|
parent = state.parent
|
||||||
_queue = state.queue.toMutableList()
|
queue.applySavedState(state.queueState)
|
||||||
repeatMode = state.repeatMode
|
repeatMode = state.repeatMode
|
||||||
isShuffled = state.isShuffled
|
|
||||||
|
|
||||||
notifyNewPlayback()
|
notifyNewPlayback()
|
||||||
notifyRepeatModeChanged()
|
notifyRepeatModeChanged()
|
||||||
notifyShuffledChanged()
|
|
||||||
|
|
||||||
// Continuing playback after drastic state updates is a bad idea, so pause.
|
// 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)
|
internalPlayer.seekTo(state.positionMs)
|
||||||
|
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|
@ -499,17 +437,16 @@ class PlaybackStateManager private constructor() {
|
||||||
*/
|
*/
|
||||||
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
|
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
|
||||||
logD("Saving state to DB")
|
logD("Saving state to DB")
|
||||||
|
|
||||||
// Create the saved state from the current playback state.
|
// Create the saved state from the current playback state.
|
||||||
val state =
|
val state =
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
PlaybackStateDatabase.SavedState(
|
queue.toSavedState()?.let {
|
||||||
index = index,
|
PlaybackStateDatabase.SavedState(
|
||||||
parent = parent,
|
parent = parent,
|
||||||
queue = _queue,
|
queueState = it,
|
||||||
positionMs = playerState.calculateElapsedPositionMs(),
|
positionMs = playerState.calculateElapsedPositionMs(),
|
||||||
isShuffled = isShuffled,
|
repeatMode = repeatMode)
|
||||||
repeatMode = repeatMode)
|
}
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
withContext(Dispatchers.IO) { database.write(state) }
|
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].
|
* Update the playback state to align with a new [Library].
|
||||||
* @param newLibrary The new [MusicStore.Library] that was recently loaded.
|
* @param newLibrary The new [Library] that was recently loaded.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sanitize(newLibrary: MusicStore.Library) {
|
fun sanitize(newLibrary: Library) {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
// Nothing playing, nothing to do.
|
// Nothing playing, nothing to do.
|
||||||
logD("Not initialized, no need to sanitize")
|
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
|
// Sanitize the queue.
|
||||||
// Song in the queue queue.
|
queue.toSavedState()?.let { state ->
|
||||||
val oldSongUid = song?.uid
|
queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) })
|
||||||
_queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
|
|
||||||
while (song?.uid != oldSongUid && index > -1) {
|
|
||||||
index--
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyNewPlayback()
|
notifyNewPlayback()
|
||||||
|
|
@ -579,8 +513,8 @@ class PlaybackStateManager private constructor() {
|
||||||
val oldPosition = playerState.calculateElapsedPositionMs()
|
val oldPosition = playerState.calculateElapsedPositionMs()
|
||||||
// Continuing playback while also possibly doing drastic state updates is
|
// Continuing playback while also possibly doing drastic state updates is
|
||||||
// a bad idea, so pause.
|
// a bad idea, so pause.
|
||||||
internalPlayer.loadSong(song, false)
|
internalPlayer.loadSong(queue.currentSong, false)
|
||||||
if (index > -1) {
|
if (queue.currentSong != null) {
|
||||||
// Internal player may have reloaded the media item, re-seek to the previous position
|
// Internal player may have reloaded the media item, re-seek to the previous position
|
||||||
seekTo(oldPosition)
|
seekTo(oldPosition)
|
||||||
}
|
}
|
||||||
|
|
@ -590,25 +524,25 @@ class PlaybackStateManager private constructor() {
|
||||||
|
|
||||||
private fun notifyIndexMoved() {
|
private fun notifyIndexMoved() {
|
||||||
for (callback in listeners) {
|
for (callback in listeners) {
|
||||||
callback.onIndexMoved(index)
|
callback.onIndexMoved(queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyQueueChanged() {
|
private fun notifyQueueChanged(change: Queue.ChangeResult) {
|
||||||
for (callback in listeners) {
|
for (callback in listeners) {
|
||||||
callback.onQueueChanged(queue)
|
callback.onQueueChanged(queue, change)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyQueueReworked() {
|
private fun notifyQueueReordered() {
|
||||||
for (callback in listeners) {
|
for (callback in listeners) {
|
||||||
callback.onQueueReworked(index, queue)
|
callback.onQueueReordered(queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyNewPlayback() {
|
private fun notifyNewPlayback() {
|
||||||
for (callback in listeners) {
|
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
|
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
|
||||||
* [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
|
* [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
|
* Called when the position of the currently playing item has changed, changing the current
|
||||||
* [Song], but no other queue attribute has changed.
|
* [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.
|
* Called when the [Queue] changed in a manner outlined by the given [Queue.ChangeResult].
|
||||||
* @param queue The new queue.
|
* @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
|
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
|
||||||
* currently playing [Song] has not.
|
* the currently playing [Song] has not.
|
||||||
* @param index The new position in the queue.
|
* @param queue The new [Queue].
|
||||||
*/
|
*/
|
||||||
fun onQueueReworked(index: Int, queue: List<Song>) {}
|
fun onQueueReordered(queue: Queue) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when a new playback configuration was created.
|
* 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.
|
* @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.
|
* Called when the state of the [InternalPlayer] changes.
|
||||||
|
|
@ -674,13 +602,6 @@ class PlaybackStateManager private constructor() {
|
||||||
* @param repeatMode The new [RepeatMode].
|
* @param repeatMode The new [RepeatMode].
|
||||||
*/
|
*/
|
||||||
fun onRepeatChanged(repeatMode: 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 {
|
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() {
|
class MediaButtonReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val playbackManager = PlaybackStateManager.getInstance()
|
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.
|
// 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
|
// 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
|
// 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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
|
@ -31,13 +30,15 @@ import androidx.media.session.MediaButtonReceiver
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.BitmapProvider
|
import org.oxycblt.auxio.image.BitmapProvider
|
||||||
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.ActionMode
|
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.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
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.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.logD
|
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) :
|
class MediaSessionComponent(private val context: Context, private val listener: Listener) :
|
||||||
MediaSessionCompat.Callback(),
|
MediaSessionCompat.Callback(),
|
||||||
PlaybackStateManager.Listener,
|
PlaybackStateManager.Listener,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
ImageSettings.Listener,
|
||||||
|
PlaybackSettings.Listener {
|
||||||
private val mediaSession =
|
private val mediaSession =
|
||||||
MediaSessionCompat(context, context.packageName).apply {
|
MediaSessionCompat(context, context.packageName).apply {
|
||||||
isActive = true
|
isActive = true
|
||||||
|
|
@ -58,13 +60,14 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
}
|
}
|
||||||
|
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
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 notification = NotificationComponent(context, mediaSession.sessionToken)
|
||||||
private val provider = BitmapProvider(context)
|
private val provider = BitmapProvider(context)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
playbackManager.addListener(this)
|
playbackManager.addListener(this)
|
||||||
|
playbackSettings.registerListener(this)
|
||||||
mediaSession.setCallback(this)
|
mediaSession.setCallback(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +85,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
*/
|
*/
|
||||||
fun release() {
|
fun release() {
|
||||||
provider.release()
|
provider.release()
|
||||||
settings.removeListener(this)
|
playbackSettings.unregisterListener(this)
|
||||||
playbackManager.removeListener(this)
|
playbackManager.removeListener(this)
|
||||||
mediaSession.apply {
|
mediaSession.apply {
|
||||||
isActive = false
|
isActive = false
|
||||||
|
|
@ -92,22 +95,38 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
|
|
||||||
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
|
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
|
||||||
|
|
||||||
override fun onIndexMoved(index: Int) {
|
override fun onIndexMoved(queue: Queue) {
|
||||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
updateMediaMetadata(queue.currentSong, playbackManager.parent)
|
||||||
invalidateSessionState()
|
invalidateSessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onQueueChanged(queue: List<Song>) {
|
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
|
||||||
updateQueue(queue)
|
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)
|
updateQueue(queue)
|
||||||
invalidateSessionState()
|
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?) {
|
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
|
||||||
updateMediaMetadata(playbackManager.song, parent)
|
updateMediaMetadata(queue.currentSong, parent)
|
||||||
updateQueue(queue)
|
updateQueue(queue)
|
||||||
invalidateSessionState()
|
invalidateSessionState()
|
||||||
}
|
}
|
||||||
|
|
@ -131,25 +150,16 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
invalidateSecondaryAction()
|
invalidateSecondaryAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShuffledChanged(isShuffled: Boolean) {
|
|
||||||
mediaSession.setShuffleMode(
|
|
||||||
if (isShuffled) {
|
|
||||||
PlaybackStateCompat.SHUFFLE_MODE_ALL
|
|
||||||
} else {
|
|
||||||
PlaybackStateCompat.SHUFFLE_MODE_NONE
|
|
||||||
})
|
|
||||||
|
|
||||||
invalidateSecondaryAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SETTINGS OVERRIDES ---
|
// --- SETTINGS OVERRIDES ---
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
override fun onCoverModeChanged() {
|
||||||
when (key) {
|
// Need to reload the metadata cover.
|
||||||
context.getString(R.string.set_key_cover_mode) ->
|
updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent)
|
||||||
updateMediaMetadata(playbackManager.song, playbackManager.parent)
|
}
|
||||||
context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction()
|
|
||||||
}
|
override fun onNotificationActionChanged() {
|
||||||
|
// Need to re-load the action shown in the notification.
|
||||||
|
invalidateSecondaryAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MEDIASESSION OVERRIDES ---
|
// --- MEDIASESSION OVERRIDES ---
|
||||||
|
|
@ -219,16 +229,13 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSetShuffleMode(shuffleMode: Int) {
|
override fun onSetShuffleMode(shuffleMode: Int) {
|
||||||
playbackManager.reshuffle(
|
playbackManager.reorder(
|
||||||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
|
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
|
||||||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP,
|
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
|
||||||
settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToQueueItem(id: Long) {
|
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?) {
|
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].
|
* Upload a new queue to the [MediaSessionCompat].
|
||||||
* @param queue The current queue to upload.
|
* @param queue The current queue to upload.
|
||||||
*/
|
*/
|
||||||
private fun updateQueue(queue: List<Song>) {
|
private fun updateQueue(queue: Queue) {
|
||||||
val queueItems =
|
val queueItems =
|
||||||
queue.mapIndexed { i, song ->
|
queue.resolve().mapIndexed { i, song ->
|
||||||
val description =
|
val description =
|
||||||
MediaDescriptionCompat.Builder()
|
MediaDescriptionCompat.Builder()
|
||||||
// Media ID should not be the item index but rather the UID,
|
// 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())
|
.intoPlaybackState(PlaybackStateCompat.Builder())
|
||||||
.setActions(ACTIONS)
|
.setActions(ACTIONS)
|
||||||
// Active queue ID corresponds to the indices we populated prior, use them here.
|
// 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.
|
// Android 13+ relies on custom actions in the notification.
|
||||||
|
|
||||||
// Add the secondary action (either repeat/shuffle depending on the configuration)
|
// Add the secondary action (either repeat/shuffle depending on the configuration)
|
||||||
val secondaryAction =
|
val secondaryAction =
|
||||||
when (settings.playbackNotificationAction) {
|
when (playbackSettings.notificationAction) {
|
||||||
ActionMode.SHUFFLE ->
|
ActionMode.SHUFFLE ->
|
||||||
PlaybackStateCompat.CustomAction.Builder(
|
PlaybackStateCompat.CustomAction.Builder(
|
||||||
PlaybackService.ACTION_INVERT_SHUFFLE,
|
PlaybackService.ACTION_INVERT_SHUFFLE,
|
||||||
context.getString(R.string.desc_shuffle),
|
context.getString(R.string.desc_shuffle),
|
||||||
if (playbackManager.isShuffled) {
|
if (playbackManager.queue.isShuffled) {
|
||||||
R.drawable.ic_shuffle_on_24
|
R.drawable.ic_shuffle_on_24
|
||||||
} else {
|
} else {
|
||||||
R.drawable.ic_shuffle_off_24
|
R.drawable.ic_shuffle_off_24
|
||||||
|
|
@ -390,8 +397,8 @@ class MediaSessionComponent(private val context: Context, private val listener:
|
||||||
private fun invalidateSecondaryAction() {
|
private fun invalidateSecondaryAction() {
|
||||||
invalidateSessionState()
|
invalidateSessionState()
|
||||||
|
|
||||||
when (settings.playbackNotificationAction) {
|
when (playbackSettings.notificationAction) {
|
||||||
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled)
|
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled)
|
||||||
else -> notification.updateRepeatMode(playbackManager.repeatMode)
|
else -> notification.updateRepeatMode(playbackManager.repeatMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,17 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
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.replaygain.ReplayGainAudioProcessor
|
||||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
import org.oxycblt.auxio.playback.state.InternalPlayer
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
|
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.widgets.WidgetComponent
|
import org.oxycblt.auxio.widgets.WidgetComponent
|
||||||
import org.oxycblt.auxio.widgets.WidgetProvider
|
import org.oxycblt.auxio.widgets.WidgetProvider
|
||||||
|
|
@ -91,7 +93,8 @@ class PlaybackService :
|
||||||
// Managers
|
// Managers
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val musicStore = MusicStore.getInstance()
|
private val musicStore = MusicStore.getInstance()
|
||||||
private lateinit var settings: Settings
|
private lateinit var musicSettings: MusicSettings
|
||||||
|
private lateinit var playbackSettings: PlaybackSettings
|
||||||
|
|
||||||
// State
|
// State
|
||||||
private lateinit var foregroundManager: ForegroundManager
|
private lateinit var foregroundManager: ForegroundManager
|
||||||
|
|
@ -142,7 +145,8 @@ class PlaybackService :
|
||||||
.also { it.addListener(this) }
|
.also { it.addListener(this) }
|
||||||
replayGainProcessor.addToListeners(player)
|
replayGainProcessor.addToListeners(player)
|
||||||
// Initialize the core service components
|
// Initialize the core service components
|
||||||
settings = Settings(this)
|
musicSettings = MusicSettings.from(this)
|
||||||
|
playbackSettings = PlaybackSettings.from(this)
|
||||||
foregroundManager = ForegroundManager(this)
|
foregroundManager = ForegroundManager(this)
|
||||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
// 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.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
|
|
@ -212,7 +216,7 @@ class PlaybackService :
|
||||||
get() = player.audioSessionId
|
get() = player.audioSessionId
|
||||||
|
|
||||||
override val shouldRewindWithPrev: Boolean
|
override val shouldRewindWithPrev: Boolean
|
||||||
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
|
get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
|
||||||
|
|
||||||
override fun getState(durationMs: Long) =
|
override fun getState(durationMs: Long) =
|
||||||
InternalPlayer.State.from(
|
InternalPlayer.State.from(
|
||||||
|
|
@ -285,7 +289,7 @@ class PlaybackService :
|
||||||
if (playbackManager.repeatMode == RepeatMode.TRACK) {
|
if (playbackManager.repeatMode == RepeatMode.TRACK) {
|
||||||
playbackManager.rewind()
|
playbackManager.rewind()
|
||||||
// May be configured to pause when we repeat a track.
|
// May be configured to pause when we repeat a track.
|
||||||
if (settings.pauseOnRepeat) {
|
if (playbackSettings.pauseOnRepeat) {
|
||||||
playbackManager.setPlaying(false)
|
playbackManager.setPlaying(false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -302,7 +306,7 @@ class PlaybackService :
|
||||||
|
|
||||||
// --- MUSICSTORE OVERRIDES ---
|
// --- MUSICSTORE OVERRIDES ---
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
// We now have a library, see if we have anything we need to do.
|
// We now have a library, see if we have anything we need to do.
|
||||||
playbackManager.requestAction(this)
|
playbackManager.requestAction(this)
|
||||||
|
|
@ -351,12 +355,16 @@ class PlaybackService :
|
||||||
}
|
}
|
||||||
// Shuffle all -> Start new playback from all songs
|
// Shuffle all -> Start new playback from all songs
|
||||||
is InternalPlayer.Action.ShuffleAll -> {
|
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
|
// Open -> Try to find the Song for the given file and then play it from all songs
|
||||||
is InternalPlayer.Action.Open -> {
|
is InternalPlayer.Action.Open -> {
|
||||||
library.findSongForUri(application, action.uri)?.let { song ->
|
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)
|
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
|
||||||
ACTION_INC_REPEAT_MODE ->
|
ACTION_INC_REPEAT_MODE ->
|
||||||
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
playbackManager.repeatMode = playbackManager.repeatMode.increment()
|
||||||
ACTION_INVERT_SHUFFLE ->
|
ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled)
|
||||||
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
|
|
||||||
ACTION_SKIP_PREV -> playbackManager.prev()
|
ACTION_SKIP_PREV -> playbackManager.prev()
|
||||||
ACTION_SKIP_NEXT -> playbackManager.next()
|
ACTION_SKIP_NEXT -> playbackManager.next()
|
||||||
ACTION_EXIT -> {
|
ACTION_EXIT -> {
|
||||||
|
|
@ -427,8 +434,8 @@ class PlaybackService :
|
||||||
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
|
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
|
||||||
// which would result in unexpected playback. Work around it by dropping the first
|
// which would result in unexpected playback. Work around it by dropping the first
|
||||||
// call to this function, which should come from that Intent.
|
// call to this function, which should come from that Intent.
|
||||||
if (settings.headsetAutoplay &&
|
if (playbackSettings.headsetAutoplay &&
|
||||||
playbackManager.song != null &&
|
playbackManager.queue.currentSong != null &&
|
||||||
initialHeadsetPlugEventHandled) {
|
initialHeadsetPlugEventHandled) {
|
||||||
logD("Device connected, resuming")
|
logD("Device connected, resuming")
|
||||||
playbackManager.setPlaying(true)
|
playbackManager.setPlaying(true)
|
||||||
|
|
@ -436,7 +443,7 @@ class PlaybackService :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pauseFromHeadsetPlug() {
|
private fun pauseFromHeadsetPlug() {
|
||||||
if (playbackManager.song != null) {
|
if (playbackManager.queue.currentSong != null) {
|
||||||
logD("Device disconnected, pausing")
|
logD("Device disconnected, pausing")
|
||||||
playbackManager.setPlaying(false)
|
playbackManager.setPlaying(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,29 +18,28 @@
|
||||||
package org.oxycblt.auxio.search
|
package org.oxycblt.auxio.search
|
||||||
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.list.*
|
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.list.recycler.*
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An adapter that displays search results.
|
* An adapter that displays search results.
|
||||||
* @param listener An [SelectableListListener] to bind interactions to.
|
* @param listener An [SelectableListListener] to bind interactions to.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SearchAdapter(private val listener: SelectableListListener) :
|
class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
||||||
SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
|
||||||
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
|
ListDiffer.Async(DIFF_CALLBACK)),
|
||||||
|
AuxioRecyclerView.SpanSizeLookup {
|
||||||
override val currentList: List<Item>
|
|
||||||
get() = differ.currentList
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (differ.currentList[position]) {
|
when (getItem(position)) {
|
||||||
is Song -> SongViewHolder.VIEW_TYPE
|
is Song -> SongViewHolder.VIEW_TYPE
|
||||||
is Album -> AlbumViewHolder.VIEW_TYPE
|
is Album -> AlbumViewHolder.VIEW_TYPE
|
||||||
is Artist -> ArtistViewHolder.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) {
|
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 Song -> (holder as SongViewHolder).bind(item, listener)
|
||||||
is Album -> (holder as AlbumViewHolder).bind(item, listener)
|
is Album -> (holder as AlbumViewHolder).bind(item, listener)
|
||||||
is Artist -> (holder as ArtistViewHolder).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
|
* Make sure that the top header has a correctly configured divider visibility. This would
|
||||||
* data..
|
* normally be automatically done by the differ, but that results in a strange animation.
|
||||||
* @param newList The new [Item]s for the adapter to display.
|
|
||||||
* @param callback A block called when the asynchronous update is completed.
|
|
||||||
*/
|
*/
|
||||||
fun submitList(newList: List<Item>, callback: () -> Unit) {
|
fun pokeDividers() {
|
||||||
differ.submitList(newList, callback)
|
notifyItemChanged(0, PAYLOAD_UPDATE_DIVIDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
val PAYLOAD_UPDATE_DIVIDER = 102249124
|
||||||
/** A comparator that can be used with DiffUtil. */
|
/** A comparator that can be used with DiffUtil. */
|
||||||
val DIFF_CALLBACK =
|
val DIFF_CALLBACK =
|
||||||
object : SimpleItemCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
|
||||||
when {
|
when {
|
||||||
oldItem is Song && newItem is Song ->
|
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.databinding.FragmentSearchBinding
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.adapter.BasicListInstructions
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
import org.oxycblt.auxio.music.MusicParent
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.settings.Settings
|
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,7 +49,7 @@ import org.oxycblt.auxio.util.*
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
|
||||||
private val searchModel: SearchViewModel by androidViewModels()
|
private val searchModel: SearchViewModel by androidViewModels()
|
||||||
private val searchAdapter = SearchAdapter(this)
|
private val searchAdapter = SearchAdapter(this)
|
||||||
private var imm: InputMethodManager? = null
|
private var imm: InputMethodManager? = null
|
||||||
|
|
@ -134,26 +133,19 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRealClick(music: Music) {
|
override fun onRealClick(item: Music) {
|
||||||
when (music) {
|
when (item) {
|
||||||
is Song ->
|
is MusicParent -> navModel.exploreNavigateTo(item)
|
||||||
when (Settings(requireContext()).libPlaybackMode) {
|
is Song -> playbackModel.playFrom(item, searchModel.playbackMode)
|
||||||
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 onOpenMenu(item: Item, anchor: View) {
|
override fun onOpenMenu(item: Music, anchor: View) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||||
is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item)
|
is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item)
|
||||||
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
||||||
is Genre -> 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
|
// Don't show the RecyclerView (and it's stray overscroll effects) when there
|
||||||
// are no results.
|
// are no results.
|
||||||
binding.searchRecycler.isInvisible = results.isEmpty()
|
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
|
// 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
|
// the query actually changes instead of once every re-creation event, but sadly
|
||||||
// that doesn't seem possible.
|
// that doesn't seem possible.
|
||||||
binding.searchRecycler.scrollToPosition(0)
|
binding.searchRecycler.scrollToPosition(0)
|
||||||
|
searchAdapter.pokeDividers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
searchAdapter.setPlayingItem(parent ?: song, isPlaying)
|
searchAdapter.setPlaying(parent ?: song, isPlaying)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNavigation(item: Music?) {
|
private fun handleNavigation(item: Music?) {
|
||||||
|
|
@ -189,7 +182,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSelection(selected: List<Music>) {
|
private fun updateSelection(selected: List<Music>) {
|
||||||
searchAdapter.setSelectedItems(selected)
|
searchAdapter.setSelected(selected.toSet())
|
||||||
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||||
selected.isNotEmpty()) {
|
selected.isNotEmpty()) {
|
||||||
// Make selection of obscured items easier by hiding the keyboard.
|
// 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.R
|
||||||
import org.oxycblt.auxio.list.Header
|
import org.oxycblt.auxio.list.Header
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.music.library.Sort
|
||||||
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
|
|
@ -45,7 +45,8 @@ import org.oxycblt.auxio.util.logD
|
||||||
class SearchViewModel(application: Application) :
|
class SearchViewModel(application: Application) :
|
||||||
AndroidViewModel(application), MusicStore.Listener {
|
AndroidViewModel(application), MusicStore.Listener {
|
||||||
private val musicStore = MusicStore.getInstance()
|
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 lastQuery: String? = null
|
||||||
private var currentSearchJob: Job? = null
|
private var currentSearchJob: Job? = null
|
||||||
|
|
||||||
|
|
@ -54,6 +55,10 @@ class SearchViewModel(application: Application) :
|
||||||
val searchResults: StateFlow<List<Item>>
|
val searchResults: StateFlow<List<Item>>
|
||||||
get() = _searchResults
|
get() = _searchResults
|
||||||
|
|
||||||
|
/** The [MusicMode] to use when playing a [Song] from the UI. */
|
||||||
|
val playbackMode: MusicMode
|
||||||
|
get() = playbackSettings.inListPlaybackMode
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicStore.addListener(this)
|
musicStore.addListener(this)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +68,7 @@ class SearchViewModel(application: Application) :
|
||||||
musicStore.removeListener(this)
|
musicStore.removeListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
override fun onLibraryChanged(library: Library?) {
|
||||||
if (library != null) {
|
if (library != null) {
|
||||||
// Make sure our query is up to date with the music library.
|
// Make sure our query is up to date with the music library.
|
||||||
search(lastQuery)
|
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 sort = Sort(Sort.Mode.ByName, true)
|
||||||
val filterMode = settings.searchFilterMode
|
val filterMode = searchSettings.searchFilterMode
|
||||||
val results = mutableListOf<Item>()
|
val results = mutableListOf<Item>()
|
||||||
|
|
||||||
// Note: A null filter mode maps to the "All" filter option, hence the check.
|
// Note: A null filter mode maps to the "All" filter option, hence the check.
|
||||||
|
|
@ -183,7 +188,7 @@ class SearchViewModel(application: Application) :
|
||||||
*/
|
*/
|
||||||
@IdRes
|
@IdRes
|
||||||
fun getFilterOptionId() =
|
fun getFilterOptionId() =
|
||||||
when (settings.searchFilterMode) {
|
when (searchSettings.searchFilterMode) {
|
||||||
MusicMode.SONGS -> R.id.option_filter_songs
|
MusicMode.SONGS -> R.id.option_filter_songs
|
||||||
MusicMode.ALBUMS -> R.id.option_filter_albums
|
MusicMode.ALBUMS -> R.id.option_filter_albums
|
||||||
MusicMode.ARTISTS -> R.id.option_filter_artists
|
MusicMode.ARTISTS -> R.id.option_filter_artists
|
||||||
|
|
@ -208,7 +213,7 @@ class SearchViewModel(application: Application) :
|
||||||
else -> error("Invalid option ID provided")
|
else -> error("Invalid option ID provided")
|
||||||
}
|
}
|
||||||
logD("Updating filter mode to $newFilterMode")
|
logD("Updating filter mode to $newFilterMode")
|
||||||
settings.searchFilterMode = newFilterMode
|
searchSettings.searchFilterMode = newFilterMode
|
||||||
search(lastQuery)
|
search(lastQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
||||||
if (pkgName == "android") {
|
if (pkgName == "android") {
|
||||||
// No default browser [Must open app chooser, may not be supported]
|
// No default browser [Must open app chooser, may not be supported]
|
||||||
openAppChooser(browserIntent)
|
openAppChooser(browserIntent)
|
||||||
} else {
|
} else
|
||||||
try {
|
try {
|
||||||
browserIntent.setPackage(pkgName)
|
browserIntent.setPackage(pkgName)
|
||||||
startActivity(browserIntent)
|
startActivity(browserIntent)
|
||||||
|
|
@ -132,7 +132,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
|
||||||
browserIntent.setPackage(null)
|
browserIntent.setPackage(null)
|
||||||
openAppChooser(browserIntent)
|
openAppChooser(browserIntent)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No app installed to open the link
|
// No app installed to open the link
|
||||||
context.showToast(R.string.err_no_app)
|
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
|
* 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
|
* 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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import androidx.annotation.StringRes
|
||||||
import android.os.Build
|
|
||||||
import android.os.storage.StorageManager
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.util.logW
|
||||||
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.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
|
* Abstract user configuration information. This interface has no functionality whatsoever. Concrete
|
||||||
* mutability is dependent on how they are used in app. Immutable members are often only modified by
|
* implementations should be preferred instead.
|
||||||
* the preferences view, while mutable members are modified elsewhere.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class Settings(private val context: Context) {
|
interface Settings<L> {
|
||||||
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate any settings from an old version into their modern counterparts. This can cause data
|
* Migrate any settings fields from older versions into their new counterparts.
|
||||||
* loss depending on the feasibility of a migration.
|
* @throws NotImplementedError If there is nothing to migrate.
|
||||||
*/
|
*/
|
||||||
fun migrate() {
|
fun migrate() {
|
||||||
if (inner.contains(OldKeys.KEY_ACCENT3)) {
|
throw NotImplementedError()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
|
* Add a listener to monitor for settings updates. Will do nothing if
|
||||||
* @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
|
* @param listener The listener to add.
|
||||||
*/
|
*/
|
||||||
fun addListener(listener: OnSharedPreferenceChangeListener) {
|
fun registerListener(listener: L)
|
||||||
inner.registerOnSharedPreferenceChangeListener(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
|
* Unregister a listener, preventing any further settings updates from being sent to it.
|
||||||
* settings updates from being sent to ti.t
|
* @param listener The listener to unregister, must be the same as the current listener.
|
||||||
*/
|
*/
|
||||||
fun removeListener(listener: OnSharedPreferenceChangeListener) {
|
fun unregisterListener(listener: L)
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What MusicParent item to play from when a Song is played from the detail view. Will be null
|
* A framework-backed [Settings] implementation.
|
||||||
* if configured to play from the currently shown item.
|
* @param context [Context] required.
|
||||||
*/
|
*/
|
||||||
val detailPlaybackMode: MusicMode?
|
abstract class Real<L>(private val context: Context) :
|
||||||
get() =
|
Settings<L>, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
MusicMode.fromIntCode(
|
protected val sharedPreferences: SharedPreferences =
|
||||||
inner.getInt(
|
PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||||
context.getString(R.string.set_key_detail_song_playback_mode), Int.MIN_VALUE))
|
|
||||||
|
|
||||||
/** Whether to keep shuffle on when playing a new Song. */
|
/** @see [Context.getString] */
|
||||||
val keepShuffle: Boolean
|
protected fun getString(@StringRes stringRes: Int) = context.getString(stringRes)
|
||||||
get() = inner.getBoolean(context.getString(R.string.set_key_keep_shuffle), true)
|
|
||||||
|
|
||||||
/** Whether to rewind when the skip previous button is pressed before skipping back. */
|
private var listener: L? = null
|
||||||
val rewindWithPrev: Boolean
|
|
||||||
get() = inner.getBoolean(context.getString(R.string.set_key_rewind_prev), true)
|
|
||||||
|
|
||||||
/** Whether a song should pause after every repeat. */
|
override fun registerListener(listener: L) {
|
||||||
val pauseOnRepeat: Boolean
|
if (this.listener == null) {
|
||||||
get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false)
|
// Registering a listener when it was null prior, attach the callback.
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
/** 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()
|
|
||||||
}
|
}
|
||||||
|
this.listener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The type of Music the search view is currently filtering to. */
|
override fun unregisterListener(listener: L) {
|
||||||
var searchFilterMode: MusicMode?
|
if (this.listener !== listener) {
|
||||||
get() =
|
logW("Given listener was not the current listener.")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
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. */
|
final override fun onSharedPreferenceChanged(
|
||||||
var libSongSort: Sort
|
sharedPreferences: SharedPreferences,
|
||||||
get() =
|
key: String
|
||||||
Sort.fromIntCode(
|
) {
|
||||||
inner.getInt(context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE))
|
onSettingChanged(key, unlikelyToBeNull(listener))
|
||||||
?: Sort(Sort.Mode.ByName, true)
|
|
||||||
set(value) {
|
|
||||||
inner.edit {
|
|
||||||
putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode)
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Album [Sort] mode used in the Home UI. */
|
/**
|
||||||
var libAlbumSort: Sort
|
* Called when a setting entry with the given [key] has changed.
|
||||||
get() =
|
* @param key The key of the changed setting.
|
||||||
Sort.fromIntCode(
|
* @param listener The implementation's listener that updates should be applied to.
|
||||||
inner.getInt(context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE))
|
*/
|
||||||
?: Sort(Sort.Mode.ByName, true)
|
protected open fun onSettingChanged(key: String, listener: L) {}
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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