Merge pull request #334 from OxygenCobalt/dev

Version 3.0.2
This commit is contained in:
Alexander Capehart 2023-01-21 23:12:03 +00:00 committed by GitHub
commit 240b4d6b2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
196 changed files with 6389 additions and 3270 deletions

View file

@ -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

View file

@ -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

View file

@ -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">

View file

@ -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 {

View 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)
}
}

View file

@ -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"

View file

@ -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
}; };
} }
} }

View file

@ -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;
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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
} }

View file

@ -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)
} }
} }

View file

@ -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)
} }
} }

View file

@ -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?,

View file

@ -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.
*/ */

View file

@ -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)
} }
} }

View file

@ -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

View file

@ -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
} }

View file

@ -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

View file

@ -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
} }

View file

@ -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 &&

View file

@ -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()

View 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)
}
}

View file

@ -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 }
} }

View file

@ -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)
} }
} }
} }

View file

@ -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)
} }
} }
} }

View file

@ -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)
} }
} }
} }

View file

@ -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)
} }
} }
} }

View file

@ -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.

View file

@ -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

View file

@ -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 }

View 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)
}
}

View file

@ -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)

View file

@ -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)
} }

View file

@ -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.

View file

@ -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)

View file

@ -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)
} }

View file

@ -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

View file

@ -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)
}
}

View 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) }
}
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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
} }

View file

@ -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 {
/** /**

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
} }

View file

@ -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
} }
} }

View file

@ -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
} }

View file

@ -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 {

View 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)
}
}

View file

@ -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 {
/** /**

View file

@ -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

View file

@ -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

View file

@ -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 }
} }
} }

View file

@ -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)

View 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
}
}

View file

@ -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))
} }

View file

@ -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)
/** /**

View file

@ -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

View file

@ -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)

View file

@ -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)
} }

View file

@ -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()
} }
} }

View file

@ -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)
} }
} }

View file

@ -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)

View file

@ -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)
} }
} }

View file

@ -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()
} }

View file

@ -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

View file

@ -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.

View file

@ -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)
} }
} }

View file

@ -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
*/ */

View file

@ -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 {
/** /**

View file

@ -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()
}
}
} }
} }

View file

@ -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)

View 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)
}
}
}

View file

@ -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)
} }
} }

View file

@ -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)

View 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)
}
}

View file

@ -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)
} }
} }

View file

@ -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
} }

View file

@ -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,

View file

@ -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)
} }
} }

View file

@ -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)
}
} }

View file

@ -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
} }

View file

@ -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)

View file

@ -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

View file

@ -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 {

View 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
}
}

View file

@ -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

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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 ->

View file

@ -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.

View 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)
}
}

View file

@ -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)
} }

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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"
} }
} }

View file

@ -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