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 }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test app with Gradle
run: ./gradlew app:testDebug
- name: Build debug APK with Gradle
run: ./gradlew app:packageDebug
- name: Upload debug APK artifact

View file

@ -1,5 +1,35 @@
# Changelog
## 3.0.2
#### What's New
- Added ability to play/shuffle selections
- Redesigned header components
- Redesigned settings view
#### What's Improved
- Added ability to edit previously played or currently playing items in the queue
- Added support for date values formatted as "YYYYMMDD"
- Pressing the button will now clear the current selection before navigating back
- Added support for non-standard `ARTISTS` tags
- Play Next and Add To Queue now start playback if there is no queue to add
#### What's Fixed
- Fixed unreliable ReplayGain adjustment application in certain situations
- Fixed crash that would occur in music folders dialog when user does not have a working
file manager
- Fixed notification not updating due to settings changes
- Fixed genre picker from repeatedly showing up when device rotates
- Fixed multi-value genres not being recognized on vorbis files
- Fixed sharp-cornered widget bar appearing even when round mode was enabled
- Fixed duplicate song items from appearing
#### What's Changed
- Implemented new queue system (will wipe state)
#### Dev/Meta
- Added unit testing framework
## 3.0.1
#### What's New

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.1">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.1&color=0D5AF5">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.2">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.2&color=0D5AF5">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">

View file

@ -12,15 +12,13 @@ android {
defaultConfig {
applicationId namespace
versionName "3.0.1"
versionCode 25
versionName "3.0.2"
versionCode 26
minSdk 21
targetSdk 33
buildFeatures {
viewBinding true
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
@ -36,8 +34,8 @@ android {
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
applicationIdSuffix ".debug"
versionNameSuffix "-DEBUG"
}
release {
@ -47,6 +45,10 @@ android {
}
}
buildFeatures {
viewBinding true
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
@ -110,8 +112,11 @@ dependencies {
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
implementation "com.google.android.material:material:1.7.0-alpha02"
// LeakCanary
// Development
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1"
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
}
spotless {

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 />
<application
android:name=".AuxioApp"
android:name=".Auxio"
android:allowBackup="true"
android:fullBackupContent="@xml/backup_descriptor"
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
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
*
* Modified at several points by Alexander Capehart to work around miscellaneous issues.
* Modified at several points by Alexander Capehart to backport miscellaneous fixes not currently
* obtainable in the currently used MDC library.
*/
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/** Listener for monitoring events about bottom sheets. */
public abstract static class BottomSheetCallback {
@ -318,9 +319,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
private int expandHalfwayActionId = View.NO_ID;
public NeoBottomSheetBehavior() {}
public BackportBottomSheetBehavior() {}
public NeoBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
public BackportBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
peekHeightGestureInsetBuffer =
@ -1980,7 +1981,7 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
skipCollapsed = source.readInt() == 1;
}
public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior<?> behavior) {
public SavedState(Parcelable superState, @NonNull BackportBottomSheetBehavior<?> behavior) {
super(superState);
this.state = behavior.state;
this.peekHeight = behavior.peekHeight;
@ -1990,12 +1991,12 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* This constructor does not respect flags: {@link NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
* NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link
* NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE}
* This constructor does not respect flags: {@link BackportBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
* BackportBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link BackportBottomSheetBehavior#SAVE_HIDEABLE}, {@link
* BackportBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link BackportBottomSheetBehavior#SAVE_NONE}
* were set.
*
* @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead.
* @deprecated Use {@link #SavedState(Parcelable, BackportBottomSheetBehavior)} instead.
*/
@Deprecated
public SavedState(Parcelable superstate, @State int state) {
@ -2036,24 +2037,24 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* A utility function to get the {@link NeoBottomSheetBehavior} associated with the {@code view}.
* A utility function to get the {@link BackportBottomSheetBehavior} associated with the {@code view}.
*
* @param view The {@link View} with {@link NeoBottomSheetBehavior}.
* @return The {@link NeoBottomSheetBehavior} associated with the {@code view}.
* @param view The {@link View} with {@link BackportBottomSheetBehavior}.
* @return The {@link BackportBottomSheetBehavior} associated with the {@code view}.
*/
@NonNull
@SuppressWarnings("unchecked")
public static <V extends View> NeoBottomSheetBehavior<V> from(@NonNull V view) {
public static <V extends View> BackportBottomSheetBehavior<V> from(@NonNull V view) {
ViewGroup.LayoutParams params = view.getLayoutParams();
if (!(params instanceof CoordinatorLayout.LayoutParams)) {
throw new IllegalArgumentException("The view is not a child of CoordinatorLayout");
}
CoordinatorLayout.Behavior<?> behavior =
((CoordinatorLayout.LayoutParams) params).getBehavior();
if (!(behavior instanceof NeoBottomSheetBehavior)) {
if (!(behavior instanceof BackportBottomSheetBehavior)) {
throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior");
}
return (NeoBottomSheetBehavior<V>) behavior;
return (BackportBottomSheetBehavior<V>) behavior;
}
/**
@ -2200,3 +2201,4 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
};
}
}

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.ImageLoaderFactory
import coil.request.CachePolicy
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
import org.oxycblt.auxio.image.extractor.MusicKeyer
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
/**
* Auxio: A simple, rational music player for android.
* A simple, rational music player for android.
* @author Alexander Capehart (OxygenCobalt)
*/
class AuxioApp : Application(), ImageLoaderFactory {
class Auxio : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Migrate any settings that may have changed in an app update.
Settings(this).migrate()
ImageSettings.from(this).migrate()
PlaybackSettings.from(this).migrate()
UISettings.from(this).migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.

View file

@ -29,7 +29,7 @@ import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
@ -81,7 +81,7 @@ class MainActivity : AppCompatActivity() {
}
private fun setupTheme() {
val settings = Settings(this)
val settings = UISettings.from(this)
// Apply the theme configuration.
AppCompatDelegate.setDefaultNightMode(settings.theme)
// Apply the color scheme. The black theme requires it's own set of themes since
@ -131,7 +131,7 @@ class MainActivity : AppCompatActivity() {
val action =
when (intent.action) {
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
else -> return false
}
playbackModel.startAction(action)

View file

@ -30,7 +30,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.MaterialFadeThrough
import kotlin.math.max
@ -101,10 +101,10 @@ class MainFragment :
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
}
}
} else {
@ -183,7 +183,7 @@ class MainFragment :
// Playback sheet intercepts queue sheet touch events, prevent that from
// occurring by disabling dragging whenever the queue sheet is expanded.
playbackSheetBehavior.isDraggable =
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED
}
} else {
// No queue sheet, fade normally based on the playback sheet
@ -235,8 +235,8 @@ class MainFragment :
tryHideAllSheets()
}
// Since the listener is also reliant on the bottom sheets, we must also update it
// every frame.
// Since the navigation listener is also reliant on the bottom sheets, we must also update
// it every frame.
callback.invalidateEnabled()
return true
@ -309,7 +309,7 @@ class MainFragment :
navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
playbackModel.finishPlaybackArtistPicker()
playbackModel.finishPlaybackGenrePicker()
}
}
@ -317,9 +317,9 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
}
}
@ -327,12 +327,12 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Make sure the queue is also collapsed here.
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior?.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
@ -340,17 +340,15 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) {
if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed
queueSheetBehavior?.isDraggable = true
playbackSheetBehavior.apply {
// Make sure the view is draggable, at least until the draw checks kick in.
isDraggable = true
state = NeoBottomSheetBehavior.STATE_COLLAPSED
state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
}
@ -359,19 +357,19 @@ class MainFragment :
val binding = requireBinding()
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply {
isDraggable = false
state = NeoBottomSheetBehavior.STATE_COLLAPSED
state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
playbackSheetBehavior.apply {
isDraggable = false
state = NeoBottomSheetBehavior.STATE_HIDDEN
state = BackportBottomSheetBehavior.STATE_HIDDEN
}
}
}
@ -390,16 +388,21 @@ class MainFragment :
// If expanded, collapse the queue sheet first.
if (queueSheetBehavior != null &&
queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) {
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
return
}
// If expanded, collapse the playback sheet next.
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) {
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
return
}
// Clear out any prior selections.
if (selectionModel.consume().isNotEmpty()) {
return
}
@ -425,8 +428,9 @@ class MainFragment :
val exploreNavController = binding.exploreNavHost.findNavController()
isEnabled =
queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED ||
selectionModel.selected.value.isNotEmpty() ||
exploreNavController.currentDestination?.id !=
exploreNavController.graph.startDestinationId
}

View file

@ -31,26 +31,22 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information about an [Album].
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
@ -88,7 +84,7 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbumUid(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, detailAdapter::submitList)
collectImmediately(detailModel.albumList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
@ -126,21 +122,12 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
}
}
override fun onRealClick(music: Music) {
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
when (Settings(requireContext()).detailPlaybackMode) {
// "Play from shown item" and "Play from album" functionally have the same
// behavior since a song can only have one album.
null,
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
override fun onRealClick(item: Song) {
// There can only be one album, so a null mode and an ALBUMS mode will function the same.
playbackModel.playFrom(item, detailModel.playbackMode ?: MusicMode.ALBUMS)
}
override fun onOpenMenu(item: Item, anchor: View) {
check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" }
override fun onOpenMenu(item: Song, anchor: View) {
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
}
@ -154,12 +141,12 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_album_sort) {
val sort = detailModel.albumSort
val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.albumSort =
detailModel.albumSongSort =
if (item.itemId == R.id.option_sort_asc) {
sort.withAscending(item.isChecked)
} else {
@ -185,10 +172,10 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
detailAdapter.setPlayingItem(song, isPlaying)
detailAdapter.setPlaying(song, isPlaying)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.setPlayingItem(null, isPlaying)
detailAdapter.setPlaying(null, isPlaying)
}
}
@ -272,8 +259,12 @@ class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAd
}
}
private fun updateList(items: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF)
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelectedItems(selected)
detailAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -31,14 +31,13 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@ -49,7 +48,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ListFragment] that shows information about an [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
@ -87,7 +87,7 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateItem)
collectImmediately(detailModel.artistList, detailAdapter::submitList)
collectImmediately(detailModel.artistList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
@ -121,27 +121,25 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
}
}
override fun onRealClick(music: Music) {
when (music) {
override fun onRealClick(item: Music) {
when (item) {
is Album -> navModel.exploreNavigateTo(item)
is Song -> {
when (Settings(requireContext()).detailPlaybackMode) {
val playbackMode = detailModel.playbackMode
if (playbackMode != null) {
playbackModel.playFrom(item, playbackMode)
} else {
// When configured to play from the selected item, we already have an Artist
// to play from.
null ->
playbackModel.playFromArtist(
music, unlikelyToBeNull(detailModel.currentArtist.value))
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
playbackModel.playFromArtist(
item, unlikelyToBeNull(detailModel.currentArtist.value))
}
}
is Album -> navModel.exploreNavigateTo(music)
else -> error("Unexpected datatype: ${music::class.simpleName}")
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item)
@ -159,13 +157,13 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_artist_sort) {
val sort = detailModel.artistSort
val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.artistSort =
detailModel.artistSongSort =
if (item.itemId == R.id.option_sort_asc) {
sort.withAscending(item.isChecked)
} else {
@ -199,7 +197,7 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
else -> null
}
detailAdapter.setPlayingItem(playingItem, isPlaying)
detailAdapter.setPlaying(playingItem, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -237,8 +235,12 @@ class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapte
}
}
private fun updateList(items: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF)
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelectedItems(selected)
detailAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -20,17 +20,19 @@ package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.filesystem.MimeType
import org.oxycblt.auxio.music.storage.MimeType
/**
* A header variation that displays a button to open a sort menu.
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class SortHeader(@StringRes val titleRes: Int) : Item
/**
* A header variation that delimits between disc groups.
* @param disc The disc number to be displayed on the header.
* @author Alexander Capehart (OxygenCobalt)
*/
data class DiscHeader(val disc: Int) : Item
@ -39,6 +41,7 @@ data class DiscHeader(val disc: Int) : Item
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
* @param sampleRateHz The sample rate, in hertz.
* @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
* @author Alexander Capehart (OxygenCobalt)
*/
data class SongProperties(
val bitrateKbps: Int?,

View file

@ -32,15 +32,13 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.filesystem.MimeType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
/**
@ -53,7 +51,8 @@ import org.oxycblt.auxio.util.*
class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application)
private val musicSettings = MusicSettings.from(application)
private val playbackSettings = PlaybackSettings.from(application)
private var currentSongJob: Job? = null
@ -81,10 +80,10 @@ class DetailViewModel(application: Application) :
get() = _albumList
/** The current [Sort] used for [Song]s in [albumList]. */
var albumSort: Sort
get() = settings.detailAlbumSort
var albumSongSort: Sort
get() = musicSettings.albumSongSort
set(value) {
settings.detailAlbumSort = value
musicSettings.albumSongSort = value
// Refresh the album list to reflect the new sort.
currentAlbum.value?.let(::refreshAlbumList)
}
@ -101,10 +100,10 @@ class DetailViewModel(application: Application) :
val artistList: StateFlow<List<Item>> = _artistList
/** The current [Sort] used for [Song]s in [artistList]. */
var artistSort: Sort
get() = settings.detailArtistSort
var artistSongSort: Sort
get() = musicSettings.artistSongSort
set(value) {
settings.detailArtistSort = value
musicSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort.
currentArtist.value?.let(::refreshArtistList)
}
@ -121,14 +120,21 @@ class DetailViewModel(application: Application) :
val genreList: StateFlow<List<Item>> = _genreList
/** The current [Sort] used for [Song]s in [genreList]. */
var genreSort: Sort
get() = settings.detailGenreSort
var genreSongSort: Sort
get() = musicSettings.genreSongSort
set(value) {
settings.detailGenreSort = value
musicSettings.genreSongSort = value
// Refresh the genre list to reflect the new sort.
currentGenre.value?.let(::refreshGenreList)
}
/**
* The [MusicMode] to use when playing a [Song] from the UI, or null to play from the currently
* shown item.
*/
val playbackMode: MusicMode?
get() = playbackSettings.inParentPlaybackMode
init {
musicStore.addListener(this)
}
@ -137,7 +143,7 @@ class DetailViewModel(application: Application) :
musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library == null) {
// Nothing to do.
return
@ -173,8 +179,8 @@ class DetailViewModel(application: Application) :
}
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong]
* and [songProperties] will be updated to align with the new [Song].
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
* [songProperties] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
@ -315,7 +321,7 @@ class DetailViewModel(application: Application) :
// To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSort.songs(album.songs)
val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1.
val byDisc = songs.groupBy { it.disc ?: 1 }
if (byDisc.size > 1) {
@ -339,21 +345,21 @@ class DetailViewModel(application: Application) :
val byReleaseGroup =
albums.groupBy {
// Remap the complicated Album.Type data structure into an easier
// Remap the complicated ReleaseType data structure into an easier
// "AlbumGrouping" enum that will automatically group and sort
// the artist's albums.
when (it.type.refinement) {
Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE
Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
null ->
when (it.type) {
is Album.Type.Album -> AlbumGrouping.ALBUMS
is Album.Type.EP -> AlbumGrouping.EPS
is Album.Type.Single -> AlbumGrouping.SINGLES
is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS
is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is Album.Type.Mix -> AlbumGrouping.MIXES
is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES
when (it.releaseType) {
is ReleaseType.Album -> AlbumGrouping.ALBUMS
is ReleaseType.EP -> AlbumGrouping.EPS
is ReleaseType.Single -> AlbumGrouping.SINGLES
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mix -> AlbumGrouping.MIXES
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
}
}
}
@ -369,7 +375,7 @@ class DetailViewModel(application: Application) :
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
data.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSort.songs(artist.songs))
data.addAll(artistSongSort.songs(artist.songs))
}
_artistList.value = data.toList()
@ -382,12 +388,12 @@ class DetailViewModel(application: Application) :
data.add(Header(R.string.lbl_artists))
data.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSort.songs(genre.songs))
data.addAll(genreSongSort.songs(genre.songs))
_genreList.value = data
}
/**
* A simpler mapping of [Album.Type] used for grouping and sorting songs.
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
* @param headerTitleRes The title string resource to use for a header created out of an
* instance of this enum.
*/

View file

@ -31,15 +31,14 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@ -50,7 +49,8 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ListFragment] that shows information for a particular [Genre].
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
@ -86,7 +86,7 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenreUid(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateItem)
collectImmediately(detailModel.genreList, detailAdapter::submitList)
collectImmediately(detailModel.genreList, ::updateList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
@ -120,26 +120,25 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
}
}
override fun onRealClick(music: Music) {
when (music) {
is Artist -> navModel.exploreNavigateTo(music)
is Song ->
when (Settings(requireContext()).detailPlaybackMode) {
// When configured to play from the selected item, we already have a Genre
override fun onRealClick(item: Music) {
when (item) {
is Artist -> navModel.exploreNavigateTo(item)
is Song -> {
val playbackMode = detailModel.playbackMode
if (playbackMode != null) {
playbackModel.playFrom(item, playbackMode)
} else {
// When configured to play from the selected item, we already have an Genre
// to play from.
null ->
playbackModel.playFromGenre(
music, unlikelyToBeNull(detailModel.currentGenre.value))
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
playbackModel.playFromGenre(
item, unlikelyToBeNull(detailModel.currentGenre.value))
}
else -> error("Unexpected datatype: ${music::class.simpleName}")
}
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
@ -157,12 +156,12 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_genre_sort) {
val sort = detailModel.genreSort
val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.genreSort =
detailModel.genreSongSort =
if (item.itemId == R.id.option_sort_asc) {
sort.withAscending(item.isChecked)
} else {
@ -184,17 +183,15 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
var item: Item? = null
var playingMusic: Music? = null
if (parent is Artist) {
item = parent
playingMusic = parent
}
// Prefer songs that might be playing from this genre.
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
item = song
playingMusic = song
}
detailAdapter.setPlayingItem(item, isPlaying)
detailAdapter.setPlaying(playingMusic, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -221,8 +218,12 @@ class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter
}
}
private fun updateList(items: List<Item>) {
detailAdapter.submitList(items, BasicListInstructions.DIFF)
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelectedItems(selected)
detailAdapter.setSelected(selected.toSet())
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -30,7 +30,7 @@ import org.oxycblt.auxio.R
*
* Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
*
* @author Alexander Capehart (OxygenCobalt)
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
class ReadOnlyTextInput
@JvmOverloads

View file

@ -29,8 +29,8 @@ import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
@ -48,7 +48,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
* An extension to [DetailAdapter.Listener] that enables interactions specific to the album
* detail view.
*/
interface Listener : DetailAdapter.Listener {
interface Listener : DetailAdapter.Listener<Song> {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
@ -57,7 +57,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
@ -75,7 +75,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = differ.currentList[position]) {
when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
@ -83,15 +83,18 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// The album and disc headers should be full-width in all configurations.
val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
val item = getItem(position)
return item is Album || item is DiscHeader
}
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Album && newItem is Album ->
@ -126,7 +129,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.type.stringRes)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.resolveName(binding.context)
@ -166,14 +169,14 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() {
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) &&
oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
oldItem.type == newItem.type
oldItem.releaseType == newItem.releaseType
}
}
}
@ -207,7 +210,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<DiscHeader>() {
object : SimpleDiffCallback<DiscHeader>() {
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
oldItem.disc == newItem.disc
}
@ -226,7 +229,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
* @param song The new [Song] to bind.
* @param listener A [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songTrack.apply {
@ -274,7 +277,7 @@ private class AlbumSongViewHolder private constructor(private val binding: ItemA
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Song>() {
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
}

View file

@ -28,10 +28,11 @@ import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
@ -42,9 +43,10 @@ import org.oxycblt.auxio.util.inflater
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
class ArtistDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
when (getItem(position)) {
// Support an artist header, and special artist albums/songs.
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
is Album -> ArtistAlbumViewHolder.VIEW_TYPE
@ -63,7 +65,7 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
// Re-binding an item with new data and not just a changed selection/playing state.
when (val item = differ.currentList[position]) {
when (val item = getItem(position)) {
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
@ -71,15 +73,17 @@ class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listen
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Artist headers should be full-width in all configurations.
val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Artist
return getItem(position) is Artist
}
private companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Artist && newItem is Artist ->
@ -109,7 +113,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
* @param artist The new [Artist] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailAdapter.Listener) {
fun bind(artist: Artist, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.resolveName(binding.context)
@ -161,7 +165,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Artist>() {
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.areGenreContentsTheSame(newItem) &&
@ -183,7 +187,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
* @param album The new [Album] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
@ -216,7 +220,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() {
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.dates == newItem.dates
}
@ -235,7 +239,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
* @param song The new [Song] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
@ -265,7 +269,7 @@ private class ArtistSongViewHolder private constructor(private val binding: Item
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Song>() {
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName &&
oldItem.album.rawName == newItem.album.rawName

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
@ -29,26 +28,29 @@ import org.oxycblt.auxio.detail.SortHeader
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.*
import org.oxycblt.auxio.list.recycler.*
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
* @param listener A [Listener] to bind interactions to.
* @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the
* @param diffCallback A [DiffUtil.ItemCallback] to use for item comparison when diffing the
* internal list.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailAdapter(
private val listener: Listener,
itemCallback: DiffUtil.ItemCallback<Item>
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
// Safe to leak this since the listener will not fire during initialization
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
private val listener: Listener<*>,
diffCallback: DiffUtil.ItemCallback<Item>
) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
ListDiffer.Async(diffCallback)),
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
when (getItem(position)) {
// Implement support for headers and sort headers
is Header -> HeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
@ -63,7 +65,7 @@ abstract class DetailAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = differ.currentList[position]) {
when (val item = getItem(position)) {
is Header -> (holder as HeaderViewHolder).bind(item)
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
}
@ -71,24 +73,12 @@ abstract class DetailAdapter(
override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = differ.currentList[position]
val item = getItem(position)
return item is Header || item is SortHeader
}
override val currentList: List<Item>
get() = differ.currentList
/**
* Asynchronously update the list with new items. Assumes that the list only contains data
* supported by the concrete [DetailAdapter] implementation.
* @param newList The new [Item]s for the adapter to display.
*/
fun submitList(newList: List<Item>) {
differ.submitList(newList)
}
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
interface Listener : SelectableListListener {
interface Listener<in T : Music> : SelectableListListener<T> {
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
/**
* Called when the play button in a detail header is pressed, requesting that the current
@ -112,7 +102,7 @@ abstract class DetailAdapter(
protected companion object {
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Header && newItem is Header ->
@ -138,7 +128,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
* @param sortHeader The new [SortHeader] to bind.
* @param listener An [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) {
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener<*>) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply {
// Add a Tooltip based on the content description so that the purpose of this
@ -162,7 +152,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<SortHeader>() {
object : SimpleDiffCallback<SortHeader>() {
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
oldItem.titleRes == newItem.titleRes
}

View file

@ -25,11 +25,12 @@ import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
@ -40,12 +41,13 @@ import org.oxycblt.auxio.util.inflater
* @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
class GenreDetailAdapter(private val listener: Listener<Music>) :
DetailAdapter(listener, DIFF_CALLBACK) {
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
when (getItem(position)) {
// Support the Genre header and generic Artist/Song items. There's nothing about
// a genre that will make the artists/songs homogeneous, so it doesn't matter what we
// use for their ViewHolders.
// a genre that will make the artists/songs specially formatted, so it doesn't matter
// what we use for their ViewHolders.
is Genre -> GenreDetailViewHolder.VIEW_TYPE
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE
@ -62,7 +64,7 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
super.onBindViewHolder(holder, position)
when (val item = differ.currentList[position]) {
when (val item = getItem(position)) {
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
is Song -> (holder as SongViewHolder).bind(item, listener)
@ -70,14 +72,16 @@ class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Genre
return getItem(position) is Genre
}
private companion object {
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Genre && newItem is Genre ->
@ -105,7 +109,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
* @param genre The new [Song] to bind.
* @param listener A [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailAdapter.Listener) {
fun bind(genre: Genre, listener: DetailAdapter.Listener<*>) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.resolveName(binding.context)
@ -135,7 +139,7 @@ private class GenreDetailViewHolder private constructor(private val binding: Ite
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Genre>() {
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size &&

View file

@ -50,6 +50,8 @@ import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
@ -143,7 +145,7 @@ class HomeFragment :
// --- VIEWMODEL SETUP ---
collect(homeModel.shouldRecreate, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::updateIndexerState)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
@ -333,10 +335,7 @@ class HomeFragment :
}
}
private fun setupCompleteState(
binding: FragmentHomeBinding,
result: Result<MusicStore.Library>
) {
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
if (result.isSuccess) {
logD("Received ok response")
binding.homeFab.show()

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
import android.app.Application
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD
/**
@ -40,15 +34,15 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt)
*/
class HomeViewModel(application: Application) :
AndroidViewModel(application),
MusicStore.Listener,
SharedPreferences.OnSharedPreferenceChangeListener {
AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application)
private val homeSettings = HomeSettings.from(application)
private val musicSettings = MusicSettings.from(application)
private val playbackSettings = PlaybackSettings.from(application)
private val _songsList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songLists: StateFlow<List<Song>>
val songsList: StateFlow<List<Song>>
get() = _songsList
private val _albumsLists = MutableStateFlow(listOf<Album>())
@ -70,11 +64,15 @@ class HomeViewModel(application: Application) :
val genresList: StateFlow<List<Genre>>
get() = _genresList
/** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode
get() = playbackSettings.inListPlaybackMode
/**
* A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s.
*/
var currentTabModes: List<MusicMode> = makeTabModes()
var currentTabModes = makeTabModes()
private set
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
@ -95,45 +93,82 @@ class HomeViewModel(application: Application) :
init {
musicStore.addListener(this)
settings.addListener(this)
homeSettings.registerListener(this)
}
override fun onCleared() {
super.onCleared()
musicStore.removeListener(this)
settings.removeListener(this)
homeSettings.unregisterListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library != null) {
logD("Library changed, refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songsList.value = settings.libSongSort.songs(library.songs)
_albumsLists.value = settings.libAlbumSort.albums(library.albums)
_songsList.value = musicSettings.songSort.songs(library.songs)
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
_artistsList.value =
settings.libArtistSort.artists(
if (settings.shouldHideCollaborators) {
musicSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
library.artists.filter { !it.isCollaborator }
} else {
library.artists
})
_genresList.value = settings.libGenreSort.genres(library.genres)
_genresList.value = musicSettings.genreSort.genres(library.genres)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
context.getString(R.string.set_key_lib_tabs) -> {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes()
_shouldRecreate.value = true
override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes()
_shouldRecreate.value = true
}
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
onLibraryChanged(musicStore.library)
}
/**
* Get the preferred [Sort] for a given [Tab].
* @param tabMode The [MusicMode] of the [Tab] desired.
* @return The [Sort] preferred for that [Tab]
*/
fun getSortForTab(tabMode: MusicMode) =
when (tabMode) {
MusicMode.SONGS -> musicSettings.songSort
MusicMode.ALBUMS -> musicSettings.albumSort
MusicMode.ARTISTS -> musicSettings.artistSort
MusicMode.GENRES -> musicSettings.genreSort
}
/**
* Update the preferred [Sort] for the current [Tab]. Will update corresponding list.
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
*/
fun setSortForCurrentTab(sort: Sort) {
logD("Updating ${_currentTabMode.value} sort to $sort")
// Can simply re-sort the current list of items without having to access the library.
when (_currentTabMode.value) {
MusicMode.SONGS -> {
musicSettings.songSort = sort
_songsList.value = sort.songs(_songsList.value)
}
context.getString(R.string.set_key_hide_collaborators) -> {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
onLibraryChanged(musicStore.library)
MusicMode.ALBUMS -> {
musicSettings.albumSort = sort
_albumsLists.value = sort.albums(_albumsLists.value)
}
MusicMode.ARTISTS -> {
musicSettings.artistSort = sort
_artistsList.value = sort.artists(_artistsList.value)
}
MusicMode.GENRES -> {
musicSettings.genreSort = sort
_genresList.value = sort.genres(_genresList.value)
}
}
}
@ -155,46 +190,6 @@ class HomeViewModel(application: Application) :
_shouldRecreate.value = false
}
/**
* Get the preferred [Sort] for a given [Tab].
* @param tabMode The [MusicMode] of the [Tab] desired.
* @return The [Sort] preferred for that [Tab]
*/
fun getSortForTab(tabMode: MusicMode) =
when (tabMode) {
MusicMode.SONGS -> settings.libSongSort
MusicMode.ALBUMS -> settings.libAlbumSort
MusicMode.ARTISTS -> settings.libArtistSort
MusicMode.GENRES -> settings.libGenreSort
}
/**
* Update the preferred [Sort] for the current [Tab]. Will update corresponding list.
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
*/
fun setSortForCurrentTab(sort: Sort) {
logD("Updating ${_currentTabMode.value} sort to $sort")
// Can simply re-sort the current list of items without having to access the library.
when (_currentTabMode.value) {
MusicMode.SONGS -> {
settings.libSongSort = sort
_songsList.value = sort.songs(_songsList.value)
}
MusicMode.ALBUMS -> {
settings.libAlbumSort = sort
_albumsLists.value = sort.albums(_albumsLists.value)
}
MusicMode.ARTISTS -> {
settings.libArtistSort = sort
_artistsList.value = sort.artists(_artistsList.value)
}
MusicMode.GENRES -> {
settings.libGenreSort = sort
_genresList.value = sort.genres(_genresList.value)
}
}
}
/**
* Update whether the user is fast scrolling or not in the home view.
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
@ -209,5 +204,6 @@ class HomeViewModel(application: Application) :
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
* the same way as the configuration.
*/
private fun makeTabModes() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
private fun makeTabModes() =
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode }
}

View file

@ -30,14 +30,12 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
@ -47,7 +45,7 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumListFragment :
ListFragment<FragmentHomeListBinding>(),
ListFragment<Album, FragmentHomeListBinding>(),
FastScrollRecyclerView.Listener,
FastScrollRecyclerView.PopupProvider {
private val homeModel: HomeViewModel by activityViewModels()
@ -69,8 +67,8 @@ class AlbumListFragment :
listener = this@AlbumListFragment
}
collectImmediately(homeModel.albumsList, albumAdapter::replaceList)
collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems)
collectImmediately(homeModel.albumsList, ::updateList)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -125,45 +123,40 @@ class AlbumListFragment :
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(music: Music) {
check(music is Album) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
override fun onRealClick(item: Album) {
navModel.exploreNavigateTo(item)
}
override fun onOpenMenu(item: Item, anchor: View) {
check(item is Album) { "Unexpected datatype: ${item::class.java}" }
override fun onOpenMenu(item: Album, anchor: View) {
openMusicMenu(anchor, R.menu.menu_album_actions, item)
}
private fun updateList(albums: List<Album>) {
albumAdapter.submitList(albums, BasicListInstructions.REPLACE)
}
private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
// If an album is playing, highlight it within this adapter.
albumAdapter.setPlayingItem(parent as? Album, isPlaying)
albumAdapter.setPlaying(parent as? Album, isPlaying)
}
/**
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class AlbumAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<AlbumViewHolder>() {
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
private class AlbumAdapter(private val listener: SelectableListListener<Album>) :
SelectionIndicatorAdapter<Album, BasicListInstructions, AlbumViewHolder>(
ListDiffer.Blocking(AlbumViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.from(parent)
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
}
/**
* Asynchronously update the list with new [Album]s.
* @param newList The new [Album]s for the adapter to display.
*/
fun replaceList(newList: List<Album>) {
differ.replaceList(newList)
holder.bind(getItem(position), listener)
}
}
}

View file

@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.nonZeroOrNull
@ -45,11 +46,11 @@ import org.oxycblt.auxio.util.nonZeroOrNull
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistListFragment :
ListFragment<FragmentHomeListBinding>(),
ListFragment<Artist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
private val homeAdapter = ArtistAdapter(this)
private val artistAdapter = ArtistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
@ -59,13 +60,13 @@ class ArtistListFragment :
binding.homeRecycler.apply {
id = R.id.home_artist_recycler
adapter = homeAdapter
adapter = artistAdapter
popupProvider = this@ArtistListFragment
listener = this@ArtistListFragment
}
collectImmediately(homeModel.artistsList, homeAdapter::replaceList)
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
collectImmediately(homeModel.artistsList, ::updateList)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -100,45 +101,40 @@ class ArtistListFragment :
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(music: Music) {
check(music is Artist) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
override fun onRealClick(item: Artist) {
navModel.exploreNavigateTo(item)
}
override fun onOpenMenu(item: Item, anchor: View) {
check(item is Artist) { "Unexpected datatype: ${item::class.java}" }
override fun onOpenMenu(item: Artist, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
}
private fun updateList(artists: List<Artist>) {
artistAdapter.submitList(artists, BasicListInstructions.REPLACE)
}
private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
// If an artist is playing, highlight it within this adapter.
homeAdapter.setPlayingItem(parent as? Artist, isPlaying)
artistAdapter.setPlaying(parent as? Artist, isPlaying)
}
/**
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class ArtistAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<ArtistViewHolder>() {
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
private class ArtistAdapter(private val listener: SelectableListListener<Artist>) :
SelectionIndicatorAdapter<Artist, BasicListInstructions, ArtistViewHolder>(
ListDiffer.Blocking(ArtistViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.from(parent)
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
}
/**
* Asynchronously update the list with new [Artist]s.
* @param newList The new [Artist]s for the adapter to display.
*/
fun replaceList(newList: List<Artist>) {
differ.replaceList(newList)
holder.bind(getItem(position), listener)
}
}
}

View file

@ -28,14 +28,15 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
@ -44,11 +45,11 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreListFragment :
ListFragment<FragmentHomeListBinding>(),
ListFragment<Genre, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
private val homeAdapter = GenreAdapter(this)
private val genreAdapter = GenreAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
@ -58,13 +59,13 @@ class GenreListFragment :
binding.homeRecycler.apply {
id = R.id.home_genre_recycler
adapter = homeAdapter
adapter = genreAdapter
popupProvider = this@GenreListFragment
listener = this@GenreListFragment
}
collectImmediately(homeModel.genresList, homeAdapter::replaceList)
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
collectImmediately(homeModel.genresList, ::updateList)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -99,45 +100,39 @@ class GenreListFragment :
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(music: Music) {
check(music is Genre) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
override fun onRealClick(item: Genre) {
navModel.exploreNavigateTo(item)
}
override fun onOpenMenu(item: Item, anchor: View) {
check(item is Genre) { "Unexpected datatype: ${item::class.java}" }
override fun onOpenMenu(item: Genre, anchor: View) {
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
}
private fun updateList(artists: List<Genre>) {
genreAdapter.submitList(artists, BasicListInstructions.REPLACE)
}
private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
// If a genre is playing, highlight it within this adapter.
homeAdapter.setPlayingItem(parent as? Genre, isPlaying)
genreAdapter.setPlaying(parent as? Genre, isPlaying)
}
/**
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class GenreAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<GenreViewHolder>() {
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
private class GenreAdapter(private val listener: SelectableListListener<Genre>) :
SelectionIndicatorAdapter<Genre, BasicListInstructions, GenreViewHolder>(
ListDiffer.Blocking(GenreViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.from(parent)
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
}
/**
* Asynchronously update the list with new [Genre]s.
* @param newList The new [Genre]s for the adapter to display.
*/
fun replaceList(newList: List<Genre>) {
differ.replaceList(newList)
holder.bind(getItem(position), listener)
}
}
}

View file

@ -30,17 +30,17 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.collectImmediately
/**
@ -48,11 +48,11 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
class SongListFragment :
ListFragment<FragmentHomeListBinding>(),
ListFragment<Song, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
private val homeAdapter = SongAdapter(this)
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb)
@ -65,13 +65,13 @@ class SongListFragment :
binding.homeRecycler.apply {
id = R.id.home_song_recycler
adapter = homeAdapter
adapter = songAdapter
popupProvider = this@SongListFragment
listener = this@SongListFragment
}
collectImmediately(homeModel.songLists, homeAdapter::replaceList)
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
collectImmediately(homeModel.songsList, ::updateList)
collectImmediately(selectionModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
@ -86,7 +86,7 @@ class SongListFragment :
}
override fun getPopup(pos: Int): String? {
val song = homeModel.songLists.value[pos]
val song = homeModel.songsList.value[pos]
// Change how we display the popup depending on the current sort mode.
// Note: We don't use the more correct individual artist name here, as sorts are largely
// based off the names of the parent objects and not the child objects.
@ -130,27 +130,28 @@ class SongListFragment :
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(music: Music) {
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
when (Settings(requireContext()).libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
override fun onRealClick(item: Song) {
playbackModel.playFrom(item, homeModel.playbackMode)
}
override fun onOpenMenu(item: Item, anchor: View) {
check(item is Song) { "Unexpected datatype: ${item::class.java}" }
override fun onOpenMenu(item: Song, anchor: View) {
openMusicMenu(anchor, R.menu.menu_song_actions, item)
}
private fun updateList(songs: List<Song>) {
songAdapter.submitList(songs, BasicListInstructions.REPLACE)
}
private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) {
homeAdapter.setPlayingItem(song, isPlaying)
songAdapter.setPlaying(song, isPlaying)
} else {
// Ignore playback that is not from all songs
homeAdapter.setPlayingItem(null, isPlaying)
songAdapter.setPlaying(null, isPlaying)
}
}
@ -158,26 +159,15 @@ class SongListFragment :
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class SongAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<SongViewHolder>() {
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
private class SongAdapter(private val listener: SelectableListListener<Song>) :
SelectionIndicatorAdapter<Song, BasicListInstructions, SongViewHolder>(
ListDiffer.Blocking(SongViewHolder.DIFF_CALLBACK)) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.from(parent)
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
}
/**
* Asynchronously update the list with new [Song]s.
* @param newList The new [Song]s for the adapter to display.
*/
fun replaceList(newList: List<Song>) {
differ.replaceList(newList)
holder.bind(getItem(position), listener)
}
}
}

View file

@ -17,7 +17,6 @@
package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE
@ -26,7 +25,7 @@ import org.oxycblt.auxio.util.logE
* @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class Tab(open val mode: MusicMode) : Item {
sealed class Tab(open val mode: MusicMode) {
/**
* A visible tab. This will be visible in the home and tab configuration views.
* @param mode The type of list in the home view this instance corresponds to.

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.
* @param listener A [EditableListListener] for tab interactions.
*/
class TabAdapter(private val listener: EditableListListener) :
class TabAdapter(private val listener: EditableListListener<Tab>) :
RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
@ -93,7 +93,7 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
* @param listener A [EditableListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab, listener: EditableListListener) {
fun bind(tab: Tab, listener: EditableListListener<Tab>) {
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode

View file

@ -25,9 +25,8 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
@ -35,7 +34,8 @@ import org.oxycblt.auxio.util.logD
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener {
class TabCustomizeDialog :
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
@ -46,13 +46,13 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), Edita
.setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes")
Settings(requireContext()).libTabs = tabAdapter.tabs
HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
var tabs = Settings(requireContext()).libTabs
var tabs = HomeSettings.from(requireContext()).homeTabs
// Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
@ -81,8 +81,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), Edita
binding.tabRecycler.adapter = null
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
check(item is Tab) { "Unexpected datatype: ${item::class.java}" }
override fun onClick(item: Tab, viewHolder: RecyclerView.ViewHolder) {
// We will need the exact index of the tab to update on in order to
// notify the adapter of the change.
val index = tabAdapter.tabs.indexOfFirst { it.mode == item.mode }

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 kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
@ -52,7 +52,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
private val settings = Settings(context)
/**
* The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius
@ -62,7 +61,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->
if (settings.roundMode) {
if (UISettings.from(context).roundMode) {
bg.setCornerSize(value)
} else {
bg.setCornerSize(0f)

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
@ -81,7 +81,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
if (Settings(context).roundMode) {
if (UISettings.from(context).roundMode) {
// Only use the specified corner radius when round mode is enabled.
setCornerSize(cornerRadius)
}

View file

@ -35,7 +35,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.library.Sort
/**
* A [Keyer] implementation for [Music] data.

View file

@ -29,8 +29,8 @@ import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -47,10 +47,8 @@ object Covers {
* loading failed or should not occur.
*/
suspend fun fetch(context: Context, album: Album): InputStream? {
val settings = Settings(context)
return try {
when (settings.coverMode) {
when (ImageSettings.from(context).coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
CoverMode.QUALITY -> fetchQualityCovers(context, album)

View file

@ -37,7 +37,8 @@ import org.oxycblt.auxio.util.showToast
* A Fragment containing a selectable list.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), SelectableListListener {
abstract class ListFragment<in T : Music, VB : ViewBinding> :
SelectionFragment<VB>(), SelectableListListener<T> {
protected val navModel: NavigationViewModel by activityViewModels()
private var currentMenu: PopupMenu? = null
@ -50,12 +51,11 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
/**
* Called when [onClick] is called, but does not result in the item being selected. This more or
* less corresponds to an [onClick] implementation in a non-[ListFragment].
* @param music The [Music] item that was clicked.
* @param item The [T] data of the item that was clicked.
*/
abstract fun onRealClick(music: Music)
abstract fun onRealClick(item: T)
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
if (selectionModel.selected.value.isNotEmpty()) {
// Map clicking an item to selecting an item when items are already selected.
selectionModel.select(item)
@ -65,8 +65,7 @@ abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), Selecta
}
}
override fun onSelect(item: Item) {
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
override fun onSelect(item: T) {
selectionModel.select(item)
}

View file

@ -25,26 +25,22 @@ import androidx.recyclerview.widget.RecyclerView
* A basic listener for list interactions.
* @author Alexander Capehart (OxygenCobalt)
*/
interface ClickableListListener {
interface ClickableListListener<in T> {
/**
* Called when an [Item] in the list is clicked.
* @param item The [Item] that was clicked.
* Called when an item in the list is clicked.
* @param item The [T] item that was clicked.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
*/
fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder)
fun onClick(item: T, viewHolder: RecyclerView.ViewHolder)
/**
* Binds this instance to a list item.
* @param item The [Item] that this list entry is bound to.
* @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] of the item that was clicked.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
*/
fun bind(
item: Item,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView
) {
fun bind(item: T, viewHolder: RecyclerView.ViewHolder, bodyView: View = viewHolder.itemView) {
bodyView.setOnClickListener { onClick(item, viewHolder) }
}
}
@ -53,7 +49,7 @@ interface ClickableListListener {
* An extension of [ClickableListListener] that enables list editing functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
interface EditableListListener : ClickableListListener {
interface EditableListListener<in T> : ClickableListListener<T> {
/**
* Called when a [RecyclerView.ViewHolder] requests that it should be dragged.
* @param viewHolder The [RecyclerView.ViewHolder] that should start being dragged.
@ -62,14 +58,14 @@ interface EditableListListener : ClickableListListener {
/**
* Binds this instance to a list item.
* @param item The [Item] that this list entry is bound to.
* @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
* @param dragHandle A touchable [View]. Any drag on this view will start a drag event.
*/
fun bind(
item: Item,
item: T,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView,
dragHandle: View
@ -89,30 +85,30 @@ interface EditableListListener : ClickableListListener {
* An extension of [ClickableListListener] that enables menu and selection functionality.
* @author Alexander Capehart (OxygenCobalt)
*/
interface SelectableListListener : ClickableListListener {
interface SelectableListListener<in T> : ClickableListListener<T> {
/**
* Called when an [Item] in the list requests that a menu related to it should be opened.
* @param item The [Item] to show a menu for.
* Called when an item in the list requests that a menu related to it should be opened.
* @param item The [T] item to open a menu for.
* @param anchor The [View] to anchor the menu to.
*/
fun onOpenMenu(item: Item, anchor: View)
fun onOpenMenu(item: T, anchor: View)
/**
* Called when an [Item] in the list requests that it be selected.
* @param item The [Item] to select.
* Called when an item in the list requests that it be selected.
* @param item The [T] item to select.
*/
fun onSelect(item: Item)
fun onSelect(item: T)
/**
* Binds this instance to a list item.
* @param item The [Item] that this list entry is bound to.
* @param item The [T] to bind this item to.
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
* @param bodyView The [View] containing the main body of the list item. Any click events on
* this [View] are routed to the listener. Defaults to the root view.
* @param menuButton A clickable [View]. Any click events on this [View] will open a menu.
*/
fun bind(
item: Item,
item: T,
viewHolder: RecyclerView.ViewHolder,
bodyView: View = viewHolder.itemView,
menuButton: View

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/>.
*/
package org.oxycblt.auxio.list.recycler
package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
abstract class PlayingIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : DiffAdapter<T, I, VH>(differFactory) {
// There are actually two states for this adapter:
// - The currently playing item, which is usually marked as "selected" and becomes accented.
// - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is
// marked as "playing" or not.
private var currentItem: Item? = null
private var currentItem: T? = null
private var isPlaying = false
/**
* The current list of the adapter. This is used to update items if the indicator state changes.
*/
abstract val currentList: List<Item>
override fun getItemCount() = currentList.size
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
// Only try to update the playing indicator if the ViewHolder supports it
if (holder is ViewHolder) {
@ -55,10 +50,10 @@ abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerV
}
/**
* Update the currently playing item in the list.
* @param item The item currently being played, or null if it is not being played.
* @param item The [T] currently being played, or null if it is not being played.
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPlayingItem(item: Item?, isPlaying: Boolean) {
fun setPlaying(item: T?, isPlaying: Boolean) {
var updatedItem = false
if (currentItem != item) {
val oldItem = currentItem

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.list.recycler
package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.RecyclerView
@ -24,11 +24,13 @@ import org.oxycblt.auxio.music.Music
/**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
* items.
* @param differFactory The [ListDiffer.Factory] that defines the type of [ListDiffer] to use.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
PlayingIndicatorAdapter<VH>() {
private var selectedItems = setOf<Music>()
abstract class SelectionIndicatorAdapter<T, I, VH : RecyclerView.ViewHolder>(
differFactory: ListDiffer.Factory<T, I>
) : PlayingIndicatorAdapter<T, I, VH>(differFactory) {
private var selectedItems = setOf<T>()
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
super.onBindViewHolder(holder, position, payloads)
@ -39,9 +41,9 @@ abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
/**
* Update the list of selected items.
* @param items A list of selected [Music].
* @param items A set of selected [T] items.
*/
fun setSelectedItems(items: List<Music>) {
fun setSelected(items: Set<T>) {
val oldSelectedItems = selectedItems
val newSelectedItems = items.toSet()
if (newSelectedItems == oldSelectedItems) {

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.list.recycler
package org.oxycblt.auxio.list.adapter
import androidx.recyclerview.widget.DiffUtil
import org.oxycblt.auxio.list.Item
@ -25,6 +25,6 @@ import org.oxycblt.auxio.list.Item
* whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class SimpleItemCallback<T : Item> : DiffUtil.ItemCallback<T>() {
abstract class SimpleDiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem
}

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,
// so we can enable fixed-size optimizations.
setHasFixedSize(true)
addItemDecoration(HeaderItemDecoration(context))
}
final override fun setHasFixedSize(hasFixedSize: Boolean) {
@ -52,6 +53,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.setHasFixedSize(hasFixedSize)
}
final override fun addItemDecoration(decor: ItemDecoration) {
super.addItemDecoration(decor)
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Update the RecyclerView's padding such that the bottom insets are applied
// while still preserving bottom padding.
@ -78,6 +83,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
/** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
interface SpanSizeLookup {
/**

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.list.Header
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
@ -45,7 +45,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
* @param song The new [Song] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(song: Song, listener: SelectableListListener) {
fun bind(song: Song, listener: SelectableListListener<Song>) {
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
@ -74,7 +74,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Song>() {
object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem)
}
@ -92,7 +92,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
* @param album The new [Album] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(album: Album, listener: SelectableListListener) {
fun bind(album: Album, listener: SelectableListListener<Album>) {
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
@ -121,11 +121,11 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() {
object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) &&
oldItem.type == newItem.type
oldItem.releaseType == newItem.releaseType
}
}
}
@ -141,7 +141,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
* @param artist The new [Artist] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: SelectableListListener) {
fun bind(artist: Artist, listener: SelectableListListener<Artist>) {
listener.bind(artist, this, menuButton = binding.parentMenu)
binding.parentImage.bind(artist)
binding.parentName.text = artist.resolveName(binding.context)
@ -180,7 +180,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Artist>() {
object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
oldItem.albums.size == newItem.albums.size &&
@ -200,7 +200,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
* @param genre The new [Genre] to bind.
* @param listener An [SelectableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: SelectableListListener) {
fun bind(genre: Genre, listener: SelectableListListener<Genre>) {
listener.bind(genre, this, menuButton = binding.parentMenu)
binding.parentImage.bind(genre)
binding.parentName.text = genre.resolveName(binding.context)
@ -233,7 +233,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Genre>() {
object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
}
@ -251,6 +251,7 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
* @param header The new [Header] to bind.
*/
fun bind(header: Header) {
logD(binding.context.getString(header.titleRes))
binding.title.text = binding.context.getString(header.titleRes)
}
@ -268,7 +269,7 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Header>() {
object : SimpleDiffCallback<Header>() {
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
oldItem.titleRes == newItem.titleRes
}

View file

@ -71,6 +71,14 @@ abstract class SelectionFragment<VB : ViewBinding> :
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_selection_play -> {
playbackModel.play(selectionModel.consume())
true
}
R.id.action_selection_shuffle -> {
playbackModel.shuffle(selectionModel.consume())
true
}
else -> false
}
}

View file

@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library
/**
* A [ViewModel] that manages the current selection.
@ -38,7 +40,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener {
musicStore.addListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library == null) {
return
}

View file

@ -21,6 +21,7 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.text.CollationKey
import java.text.Collator
@ -30,10 +31,12 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.filesystem.*
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -308,10 +311,10 @@ sealed class MusicParent : Music() {
/**
* A song. Perhaps the foundation of the entirety of Auxio.
* @param raw The [Song.Raw] to derive the member data from.
* @param settings [Settings] to determine the artist configuration.
* @param musicSettings [MusicSettings] to perform further user-configured parsing.
* @author Alexander Capehart (OxygenCobalt)
*/
class Song constructor(raw: Raw, settings: Settings) : Music() {
class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
@ -381,9 +384,9 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
val album: Album
get() = unlikelyToBeNull(_album)
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings)
private val artistNames = raw.artistNames.parseMultiValue(settings)
private val artistSortNames = raw.artistSortNames.parseMultiValue(settings)
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
private val rawArtists =
artistNames.mapIndexed { i, name ->
Artist.Raw(
@ -392,9 +395,10 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
artistSortNames.getOrNull(i))
}
private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings)
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings)
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings)
private val albumArtistMusicBrainzIds =
raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings)
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings)
private val rawAlbumArtists =
albumArtistNames.mapIndexed { i, name ->
Artist.Raw(
@ -462,7 +466,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName,
type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings)),
releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)),
rawArtists =
rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
@ -481,7 +485,7 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
*/
val _rawGenres =
raw.genreNames
.parseId3GenreNames(settings)
.parseId3GenreNames(musicSettings)
.map { Genre.Raw(it) }
.ifEmpty { listOf(Genre.Raw()) }
@ -581,8 +585,8 @@ class Song constructor(raw: Raw, settings: Settings) : Music() {
var albumName: String? = null,
/** @see Album.Raw.sortName */
var albumSortName: String? = null,
/** @see Album.Raw.type */
var albumTypes: List<String> = listOf(),
/** @see Album.Raw.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
@ -628,10 +632,10 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
val dates = Date.Range.from(songs.mapNotNull { it.date })
/**
* The [Type] of this album, signifying the type of release it actually is. Defaults to
* [Type.Album].
* The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
* [ReleaseType.Album].
*/
val type = raw.type ?: Type.Album(null)
val releaseType = raw.releaseType ?: ReleaseType.Album(null)
/**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality.
@ -726,201 +730,6 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
}
}
/**
* The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc.
*
* This class is derived from the MusicBrainz Release Group Type specification. It can be found
* at: https://musicbrainz.org/doc/Release_Group/Type
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class Type {
/**
* A specification of what kind of performance this release is. If null, the release is
* considered "Plain".
*/
abstract val refinement: Refinement?
/** The string resource corresponding to the name of this release type to show in the UI. */
abstract val stringRes: Int
/**
* A plain album.
* @param refinement A specification of what kind of performance this release is. If null,
* the release is considered "Plain".
*/
data class Album(override val refinement: Refinement?) : Type() {
override val stringRes: Int
get() =
when (refinement) {
null -> R.string.lbl_album
// If present, include the refinement in the name of this release type.
Refinement.LIVE -> R.string.lbl_album_live
Refinement.REMIX -> R.string.lbl_album_remix
}
}
/**
* A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs.
* @param refinement A specification of what kind of performance this release is. If null,
* the release is considered "Plain".
*/
data class EP(override val refinement: Refinement?) : Type() {
override val stringRes: Int
get() =
when (refinement) {
null -> R.string.lbl_ep
// If present, include the refinement in the name of this release type.
Refinement.LIVE -> R.string.lbl_ep_live
Refinement.REMIX -> R.string.lbl_ep_remix
}
}
/**
* A single. Usually a release consisting of 1-2 songs.
* @param refinement A specification of what kind of performance this release is. If null,
* the release is considered "Plain".
*/
data class Single(override val refinement: Refinement?) : Type() {
override val stringRes: Int
get() =
when (refinement) {
null -> R.string.lbl_single
// If present, include the refinement in the name of this release type.
Refinement.LIVE -> R.string.lbl_single_live
Refinement.REMIX -> R.string.lbl_single_remix
}
}
/**
* A compilation. Usually consists of many songs from a variety of artists.
* @param refinement A specification of what kind of performance this release is. If null,
* the release is considered "Plain".
*/
data class Compilation(override val refinement: Refinement?) : Type() {
override val stringRes: Int
get() =
when (refinement) {
null -> R.string.lbl_compilation
// If present, include the refinement in the name of this release type.
Refinement.LIVE -> R.string.lbl_compilation_live
Refinement.REMIX -> R.string.lbl_compilation_remix
}
}
/**
* A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually
* visual) media.
*/
object Soundtrack : Type() {
override val refinement: Refinement?
get() = null
override val stringRes: Int
get() = R.string.lbl_soundtrack
}
/**
* A (DJ) Mix. These are usually one large track consisting of the artist playing several
* sub-tracks with smooth transitions between them.
*/
object Mix : Type() {
override val refinement: Refinement?
get() = null
override val stringRes: Int
get() = R.string.lbl_mix
}
/**
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or
* a future release.
*/
object Mixtape : Type() {
override val refinement: Refinement?
get() = null
override val stringRes: Int
get() = R.string.lbl_mixtape
}
/** A specification of what kind of performance a particular release is. */
enum class Refinement {
/** A release consisting of a live performance */
LIVE,
/** A release consisting of another [Artist]s remix of a prior performance. */
REMIX
}
companion object {
/**
* Parse a [Type] from a string formatted with the MusicBrainz Release Group Type
* specification.
* @param types A list of values consisting of valid release type values.
* @return A [Type] consisting of the given types, or null if the types were not valid.
*/
fun parse(types: List<String>): Type? {
val primary = types.getOrNull(0) ?: return null
return when {
// Primary types should be the first types in the sequence.
primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) }
primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) }
primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) }
// The spec makes no mention of whether primary types are a pre-requisite for
// secondary types, so we assume that it's not and map oprhan secondary types
// to Album release types.
else -> types.parseSecondaryTypes(0) { Album(it) }
}
}
/**
* Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted
* with the MusicBrainz Release Group Type specification.
* @param index The index of the release type to parse.
* @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding
* to the callee's context. This is used in order to handle secondary times that are
* actually [Refinement]s.
* @return A [Type] corresponding to the secondary type found at that index.
*/
private inline fun List<String>.parseSecondaryTypes(
index: Int,
convertRefinement: (Refinement?) -> Type
): Type {
val secondary = getOrNull(index)
return if (secondary.equals("compilation", true)) {
// Secondary type is a compilation, actually parse the third type
// and put that into a compilation if needed.
parseSecondaryTypeImpl(getOrNull(index + 1)) { Compilation(it) }
} else {
// Secondary type is a plain value, use the original values given.
parseSecondaryTypeImpl(secondary, convertRefinement)
}
}
/**
* Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to
* any child values.
* @param type The release type value to parse.
* @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding
* to the callee's context. This is used in order to handle secondary times that are
* actually [Refinement]s.
*/
private inline fun parseSecondaryTypeImpl(
type: String?,
convertRefinement: (Refinement?) -> Type
) =
when {
// Parse all the types that have no children
type.equals("soundtrack", true) -> Soundtrack
type.equals("mixtape/street", true) -> Mixtape
type.equals("dj-mix", true) -> Mix
type.equals("live", true) -> convertRefinement(Refinement.LIVE)
type.equals("remix", true) -> convertRefinement(Refinement.REMIX)
else -> convertRefinement(null)
}
}
}
/**
* Raw information about an [Album] obtained from the component [Song] instances. **This is only
* meant for use within the music package.**
@ -937,8 +746,8 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
val name: String,
/** @see Music.rawSortName */
val sortName: String?,
/** @see Album.type */
val type: Type?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see Artist.Raw.name */
val rawArtists: List<Artist.Raw>
) {
@ -955,16 +764,15 @@ class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent(
override fun hashCode() = hashCode
override fun equals(other: Any?): Boolean {
if (other !is Raw) return false
if (musicBrainzId != null &&
other.musicBrainzId != null &&
musicBrainzId == other.musicBrainzId) {
return true
}
return name.equals(other.name, true) && rawArtists == other.rawArtists
}
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
}
@ -1108,21 +916,19 @@ class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicP
override fun hashCode() = hashCode
override fun equals(other: Any?): Boolean {
if (other !is Raw) return false
if (musicBrainzId != null &&
other.musicBrainzId != null &&
musicBrainzId == other.musicBrainzId) {
return true
}
return when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
}
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
}
}
@ -1217,7 +1023,7 @@ class Genre constructor(private val raw: Raw, override val songs: List<Song>) :
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
* @see UUID.fromString
*/
fun String.toUuidOrNull(): UUID? =
private fun String.toUuidOrNull(): UUID? =
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
@ -1228,7 +1034,8 @@ fun String.toUuidOrNull(): UUID? =
* Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed.
*/
private fun MessageDigest.update(string: String?) {
@VisibleForTesting
fun MessageDigest.update(string: String?) {
if (string != null) {
update(string.lowercase().toByteArray())
} else {
@ -1240,7 +1047,8 @@ private fun MessageDigest.update(string: String?) {
* Update a [MessageDigest] with the string representation of a [Date].
* @param date The [Date] to hash. If null, nothing will be done.
*/
private fun MessageDigest.update(date: Date?) {
@VisibleForTesting
fun MessageDigest.update(date: Date?) {
if (date != null) {
update(date.toString().toByteArray())
} else {
@ -1252,7 +1060,8 @@ private fun MessageDigest.update(date: Date?) {
* Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
* @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
*/
private fun MessageDigest.update(strings: List<String?>) {
@VisibleForTesting
fun MessageDigest.update(strings: List<String?>) {
strings.forEach(::update)
}
@ -1260,7 +1069,8 @@ private fun MessageDigest.update(strings: List<String?>) {
* Update a [MessageDigest] with the little-endian bytes of a [Int].
* @param n The [Int] to write. If null, nothing will be done.
*/
private fun MessageDigest.update(n: Int?) {
@VisibleForTesting
fun MessageDigest.update(n: Int?) {
if (n != null) {
update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
} else {

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
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.useQuery
import org.oxycblt.auxio.music.library.Library
/**
* A repository granting access to the music library..
* A repository granting access to the music library.
*
* This can be used to obtain certain music items, or await changes to the music library. It is
* generally recommended to use this over Indexer to keep track of the library state, as the
@ -62,7 +58,7 @@ class MusicStore private constructor() {
}
/**
* Remove a [Listener] from this instance, preventing it from recieving any further updates.
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
* @see Listener
@ -72,101 +68,6 @@ class MusicStore private constructor() {
listeners.remove(listener)
}
/**
* A library of [Music] instances.
* @param songs All [Song]s loaded from the device.
* @param albums All [Album]s that could be created.
* @param artists All [Artist]s that could be created.
* @param genres All [Genre]s that could be created.
*/
data class Library(
val songs: List<Song>,
val albums: List<Album>,
val artists: List<Artist>,
val genres: List<Genre>,
) {
private val uidMap = HashMap<Music.UID, Music>()
init {
// The data passed to Library initially are complete, but are still volitaile.
// Finalize them to ensure they are well-formed. Also initialize the UID map in
// the same loop for efficiency.
for (song in songs) {
song._finalize()
uidMap[song.uid] = song
}
for (album in albums) {
album._finalize()
uidMap[album.uid] = album
}
for (artist in artists) {
artist._finalize()
uidMap[artist.uid] = artist
}
for (genre in genres) {
genre._finalize()
uidMap[genre.uid] = genre
}
}
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
* @param uid The [Music.UID] to search for.
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found
* or the [Music.UID] did not correspond to a [T].
*/
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
/**
* Convert a [Song] from an another library into a [Song] in this [Library].
* @param song The [Song] to convert.
* @return The analogous [Song] in this [Library], or null if it does not exist.
*/
fun sanitize(song: Song) = find<Song>(song.uid)
/**
* Convert a [Album] from an another library into a [Album] in this [Library].
* @param album The [Album] to convert.
* @return The analogous [Album] in this [Library], or null if it does not exist.
*/
fun sanitize(album: Album) = find<Album>(album.uid)
/**
* Convert a [Artist] from an another library into a [Artist] in this [Library].
* @param artist The [Artist] to convert.
* @return The analogous [Artist] in this [Library], or null if it does not exist.
*/
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
/**
* Convert a [Genre] from an another library into a [Genre] in this [Library].
* @param genre The [Genre] to convert.
* @return The analogous [Genre] in this [Library], or null if it does not exist.
*/
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// song. Do what we can to hopefully find the song the user wanted to open.
val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size }
}
}
/** A listener for changes in the music library. */
interface Listener {
/**

View file

@ -23,10 +23,10 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.parsing.correctWhitespace
import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.*
/**
@ -142,7 +142,7 @@ class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtr
rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
rawSong.albumName = cachedRawSong.albumName
rawSong.albumSortName = cachedRawSong.albumSortName
rawSong.albumTypes = cachedRawSong.albumTypes
rawSong.releaseTypes = cachedRawSong.releaseTypes
rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
rawSong.artistNames = cachedRawSong.artistNames
@ -190,7 +190,7 @@ private class CacheDatabase(context: Context) :
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
append("${Columns.ALBUM_SORT_NAME} STRING,")
append("${Columns.ALBUM_TYPES} STRING,")
append("${Columns.RELEASE_TYPES} STRING,")
append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
append("${Columns.ARTIST_NAMES} STRING,")
append("${Columns.ARTIST_SORT_NAMES} STRING,")
@ -249,7 +249,7 @@ private class CacheDatabase(context: Context) :
cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME)
val albumTypesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_TYPES)
val releaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.RELEASE_TYPES)
val artistMusicBrainzIdsIndex =
cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
@ -286,8 +286,8 @@ private class CacheDatabase(context: Context) :
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex)
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
cursor.getStringOrNull(albumTypesIndex)?.let {
raw.albumTypes = it.parseSQLMultiValue()
cursor.getStringOrNull(releaseTypesIndex)?.let {
raw.releaseTypes = it.parseSQLMultiValue()
}
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
@ -351,7 +351,7 @@ private class CacheDatabase(context: Context) :
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
put(Columns.ALBUM_NAME, rawSong.albumName)
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
put(Columns.RELEASE_TYPES, rawSong.releaseTypes.toSQLMultiValue())
put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue())
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
@ -422,8 +422,8 @@ private class CacheDatabase(context: Context) :
const val ALBUM_NAME = "album"
/** @see Song.Raw.albumSortName */
const val ALBUM_SORT_NAME = "album_sort"
/** @see Song.Raw.albumTypes */
const val ALBUM_TYPES = "album_types"
/** @see Song.Raw.releaseTypes */
const val RELEASE_TYPES = "album_types"
/** @see Song.Raw.artistMusicBrainzIds */
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
/** @see Song.Raw.artistNames */
@ -442,7 +442,7 @@ private class CacheDatabase(context: Context) :
companion object {
private const val DB_NAME = "auxio_music_cache.db"
private const val DB_VERSION = 1
private const val DB_VERSION = 2
private const val TABLE_RAW_SONGS = "raw_songs"
@Volatile private var INSTANCE: CacheDatabase? = null

View file

@ -27,17 +27,17 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.directoryCompat
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.filesystem.safeQuery
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
import org.oxycblt.auxio.music.filesystem.useQuery
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
@ -86,20 +86,20 @@ abstract class MediaStoreExtractor(
open fun init(): Cursor {
val start = System.currentTimeMillis()
cacheExtractor.init()
val settings = Settings(context)
val musicSettings = MusicSettings.from(context)
val storageManager = context.getSystemServiceCompat(StorageManager::class)
val args = mutableListOf<String>()
var selector = BASE_SELECTOR
// Filter out audio that is not music, if enabled.
if (settings.excludeNonMusic) {
if (musicSettings.excludeNonMusic) {
logD("Excluding non-music")
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
}
// Set up the projection to follow the music directory configuration.
val dirs = settings.getMusicDirs(storageManager)
val dirs = musicSettings.musicDirs
if (dirs.dirs.isNotEmpty()) {
selector += " AND "
if (!dirs.shouldInclude) {
@ -305,7 +305,7 @@ abstract class MediaStoreExtractor(
// MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
raw.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to

View file

@ -21,10 +21,11 @@ import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import org.oxycblt.auxio.music.Date
import kotlinx.coroutines.flow.flow
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.filesystem.toAudioUri
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -61,12 +62,11 @@ class MetadataExtractor(
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
/**
* Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the
* sub-extractors before parsing the metadata itself.
* @param emit A listener that will be invoked with every new [Song.Raw] instance when they are
* successfully loaded.
* Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will
* first delegate to the sub-extractors before parsing the metadata itself.
* @return A flow of [Song.Raw] instances.
*/
suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
fun extract() = flow {
while (true) {
val raw = Song.Raw()
when (mediaStoreExtractor.populate(raw)) {
@ -160,9 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
val metadata = format.metadata
if (metadata != null) {
val tags = Tags(metadata)
populateWithId3v2(tags.id3v2)
populateWithVorbis(tags.vorbis)
val textTags = TextTags(metadata)
populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis)
} else {
logD("No metadata could be extracted for ${raw.name}")
}
@ -207,18 +207,20 @@ class Task(context: Context, private val raw: Song.Raw) {
textFrames["TALB"]?.let { raw.albumName = it[0] }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.albumTypes = it
raw.releaseTypes = it
}
// Artist
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TPE1"]?.let { raw.artistNames = it }
textFrames["TSOP"]?.let { raw.artistSortNames = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it }
// Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it }
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
raw.albumArtistSortNames = it
}
// Genre
textFrames["TCON"]?.let { raw.genreNames = it }
@ -229,7 +231,7 @@ class Task(context: Context, private val raw: Song.Raw) {
* Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values.
* @retrn A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
* @return A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
* hour/minute value from TIME. No second value is included. The latter two fields may not be
* included in they cannot be parsed. Will be null if a year value could not be parsed.
*/
@ -292,26 +294,28 @@ class Task(context: Context, private val raw: Song.Raw) {
// date tag that android supports, so it must be 15 years old or more!)
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
?: comments["year"]?.run { Date.from(first()) })
?.let { raw.date = it }
// Album
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
comments["album"]?.let { raw.albumName = it[0] }
comments["albumsort"]?.let { raw.albumSortName = it[0] }
comments["releasetype"]?.let { raw.albumTypes = it }
comments["releasetype"]?.let { raw.releaseTypes = it }
// Artist
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
comments["artist"]?.let { raw.artistNames = it }
comments["artistsort"]?.let { raw.artistSortNames = it }
(comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it }
// Album artist
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["albumartist"]?.let { raw.albumArtistNames = it }
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
raw.albumArtistSortNames = it
}
// Genre
comments["GENRE"]?.let { raw.genreNames = it }
comments["genre"]?.let { raw.genreNames = it }
}
}

View file

@ -24,11 +24,11 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.parsing.correctWhitespace
/**
* Processing wrapper for [Metadata] that allows access to more organized music tags.
* Processing wrapper for [Metadata] that allows organized access to text-based audio tags.
* @param metadata The [Metadata] to wrap.
* @author Alexander Capehart (OxygenCobalt)
*/
class Tags(metadata: Metadata) {
class TextTags(metadata: Metadata) {
private val _id3v2 = mutableMapOf<String, List<String>>()
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
val id3v2: Map<String, List<String>>
@ -65,6 +65,10 @@ class Tags(metadata: Metadata) {
is VorbisComment -> {
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
val id = tag.key.sanitize().lowercase()
if (id == "metadata_block_picture") {
// Picture, we don't care about these
continue
}
val value = tag.value.sanitize().correctWhitespace()
if (value != null) {
_vorbis.getOrPut(id) { mutableListOf() }.add(value)

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/>.
*/
package org.oxycblt.auxio.music
package org.oxycblt.auxio.music.library
import androidx.annotation.IdRes
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Sort.Mode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Sort.Mode
import org.oxycblt.auxio.music.tags.Date
/**
* A sorting method.
@ -95,7 +97,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* @param songs The [Song]s to sort.
*/
fun songsInPlace(songs: MutableList<Song>) {
private fun songsInPlace(songs: MutableList<Song>) {
songs.sortWith(mode.getSongComparator(isAscending))
}

View file

@ -17,7 +17,7 @@
package org.oxycblt.auxio.music.parsing
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.util.nonZeroOrNull
/// --- GENERIC PARSING ---
@ -26,10 +26,10 @@ import org.oxycblt.auxio.util.nonZeroOrNull
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration.
* @param settings [MusicSettings] required to obtain user separator configuration.
* @return A new list of one or more [String]s.
*/
fun List<String>.parseMultiValue(settings: Settings) =
fun List<String>.parseMultiValue(settings: MusicSettings) =
if (size == 1) {
first().maybeParseBySeparators(settings)
} else {
@ -99,10 +99,9 @@ fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
* @param settings [Settings] required to obtain user separator configuration.
* @return A list of one or more [String]s that were split up by the user-defined separators.
*/
private fun String.maybeParseBySeparators(settings: Settings): List<String> {
private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
// Get the separators the user desires. If null, there's nothing to do.
val separators = settings.musicSeparators ?: return listOf(this)
return splitEscaped { separators.contains(it) }.correctWhitespace()
return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace()
}
/// --- ID3v2 PARSING ---
@ -119,10 +118,10 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
* representations of genre fields into their named counterparts, and split up singular ID3v2-style
* integer genre fields into one or more genres.
* @param settings [Settings] required to obtain user separator configuration.
* @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more genre names..
*/
fun List<String>.parseId3GenreNames(settings: Settings) =
fun List<String>.parseId3GenreNames(settings: MusicSettings) =
if (size == 1) {
first().parseId3MultiValueGenre(settings)
} else {
@ -132,9 +131,10 @@ fun List<String>.parseId3GenreNames(settings: Settings) =
/**
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
* @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more genre names.
*/
private fun String.parseId3MultiValueGenre(settings: Settings) =
private fun String.parseId3MultiValueGenre(settings: MusicSettings) =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
/**

View file

@ -25,7 +25,7 @@ import com.google.android.material.checkbox.MaterialCheckBox
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
/**
@ -42,7 +42,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
.setTitle(R.string.set_separators)
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
Settings(requireContext()).musicSeparators = getCurrentSeparators()
MusicSettings.from(requireContext()).multiValueSeparators = getCurrentSeparators()
}
}
@ -59,8 +59,8 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
// the corresponding CheckBox for each character instead of doing an iteration
// through the separator list for each CheckBox.
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
?: Settings(requireContext()).musicSeparators)
?.forEach {
?: MusicSettings.from(requireContext()).multiValueSeparators)
.forEach {
when (it) {
Separators.COMMA -> binding.separatorComma.isChecked = true
Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater
* @param listener A [ClickableListListener] to bind interactions to.
* @author OxygenCobalt.
*/
class ArtistChoiceAdapter(private val listener: ClickableListListener) :
class ArtistChoiceAdapter(private val listener: ClickableListListener<Artist>) :
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
private var artists = listOf<Artist>()
@ -67,7 +67,7 @@ class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param artist The new [Artist] to bind.
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(artist: Artist, listener: ClickableListListener) {
fun bind(artist: Artist, listener: ClickableListListener<Artist>) {
listener.bind(artist, this)
binding.pickerImage.bind(artist)
binding.pickerName.text = artist.resolveName(binding.context)

View file

@ -22,7 +22,6 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.NavigationViewModel
@ -41,9 +40,8 @@ class ArtistNavigationPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState)
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
super.onClick(item, viewHolder)
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
// User made a choice, navigate to it.
navModel.exploreNavigateTo(item)
}

View file

@ -26,7 +26,6 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.collectImmediately
@ -38,7 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ArtistPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Artist> {
protected val pickerModel: PickerViewModel by viewModels()
// Okay to leak this since the Listener will not be called until after initialization.
private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this)
@ -68,7 +67,7 @@ abstract class ArtistPickerDialog :
binding.pickerRecycler.adapter = null
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
findNavController().navigateUp()
}
}

View file

@ -21,11 +21,12 @@ import android.os.Bundle
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.requireIs
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous.
@ -42,12 +43,10 @@ class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
super.onBindingCreated(binding, savedInstanceState)
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
super.onClick(item, viewHolder)
// User made a choice, play the given song from that artist.
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
val song = requireIs<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
playbackModel.playFromArtist(song, item)
}
}

View file

@ -32,7 +32,7 @@ import org.oxycblt.auxio.util.inflater
* @param listener A [ClickableListListener] to bind interactions to.
* @author OxygenCobalt.
*/
class GenreChoiceAdapter(private val listener: ClickableListListener) :
class GenreChoiceAdapter(private val listener: ClickableListListener<Genre>) :
RecyclerView.Adapter<GenreChoiceViewHolder>() {
private var genres = listOf<Genre>()
@ -67,7 +67,7 @@ class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
* @param genre The new [Genre] to bind.
* @param listener A [ClickableListListener] to bind interactions to.
*/
fun bind(genre: Genre, listener: ClickableListListener) {
fun bind(genre: Genre, listener: ClickableListListener<Genre>) {
listener.bind(genre, this)
binding.pickerImage.bind(genre)
binding.pickerName.text = genre.resolveName(binding.context)

View file

@ -27,20 +27,21 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.requireIs
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenrePlaybackPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Genre> {
private val pickerModel: PickerViewModel by viewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
// Information about what Song to show choices for is initially within the navigation arguments
@ -75,11 +76,9 @@ class GenrePlaybackPickerDialog :
binding.pickerRecycler.adapter = null
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
override fun onClick(item: Genre, viewHolder: RecyclerView.ViewHolder) {
// User made a choice, play the given song from that genre.
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
val song = pickerModel.currentItem.value
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
val song = requireIs<Song>(unlikelyToBeNull(pickerModel.currentItem.value))
playbackModel.playFromGenre(song, item)
}
}

View file

@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -50,7 +52,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener {
musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library != null) {
refreshChoices()
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.filesystem
package org.oxycblt.auxio.music.storage
import android.view.View
import android.view.ViewGroup

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.filesystem
package org.oxycblt.auxio.music.storage
import android.content.Context
import android.media.MediaFormat
@ -129,7 +129,6 @@ class Directory private constructor(val volume: StorageVolume, val relativePath:
* @author Alexander Capehart (OxygenCobalt)
*/
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
// TODO: Unify include + exclude
/**
* A mime type of a file. Only intended for display.

View file

@ -15,8 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.filesystem
package org.oxycblt.auxio.music.storage
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.os.storage.StorageManager
@ -25,11 +26,12 @@ import android.view.LayoutInflater
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
@ -49,20 +51,15 @@ class MusicDirsDialog :
DialogMusicDirsBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
// Don't set the click listener here, we do some custom magic in onCreateView instead.
builder
.setTitle(R.string.set_dirs)
.setNeutralButton(R.string.lbl_add, null)
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
val settings = Settings(requireContext())
val dirs =
settings.getMusicDirs(
requireNotNull(storageManager) { "StorageManager was not available" })
val settings = MusicSettings.from(requireContext())
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (dirs != newDirs) {
if (settings.musicDirs != newDirs) {
logD("Committing changes")
settings.setMusicDirs(newDirs)
settings.musicDirs = newDirs
}
}
}
@ -76,18 +73,21 @@ class MusicDirsDialog :
registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
// Now that the dialog exists, we get the view manually when the dialog is shown
// and override its click listener so that the dialog does not auto-dismiss when we
// click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former
// and the app from crashing in the latter.
requireDialog().setOnShowListener {
val dialog = it as AlertDialog
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
binding.dirsAdd.apply {
ViewCompat.setTooltipText(this, contentDescription)
setOnClickListener {
logD("Opening launcher")
requireNotNull(openDocumentTreeLauncher) {
val launcher =
requireNotNull(openDocumentTreeLauncher) {
"Document tree launcher was not available"
}
.launch(null)
try {
launcher.launch(null)
} catch (e: ActivityNotFoundException) {
// User doesn't have a capable file manager.
requireContext().showToast(R.string.err_no_app)
}
}
}
@ -96,8 +96,7 @@ class MusicDirsDialog :
itemAnimator = null
}
var dirs = Settings(context).getMusicDirs(storageManager)
var dirs = MusicSettings.from(context).musicDirs
if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) {
@ -178,8 +177,12 @@ class MusicDirsDialog :
private fun updateMode() {
val binding = requireBinding()
if (isUiModeInclude(binding)) {
binding.dirsModeExclude.icon = null
binding.dirsModeInclude.setIconResource(R.drawable.ic_check_24)
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
} else {
binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24)
binding.dirsModeInclude.icon = null
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
}
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.filesystem
package org.oxycblt.auxio.music.storage
import android.annotation.SuppressLint
import android.content.ContentResolver
@ -196,7 +196,7 @@ val StorageVolume.isInternalCompat: Boolean
get() = isPrimaryCompat && isEmulatedCompat
/**
* The unique identifier for this [StorageVolume], obtained in a version compatible manner Can be
* The unique identifier for this [StorageVolume], obtained in a version compatible manner. Can be
* null.
* @see StorageVolume.getUuid
*/

View file

@ -27,15 +27,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
@ -51,7 +45,7 @@ import org.oxycblt.auxio.util.logW
* @author Alexander Capehart (OxygenCobalt)
*/
class Indexer private constructor() {
@Volatile private var lastResponse: Result<MusicStore.Library>? = null
@Volatile private var lastResponse: Result<Library>? = null
@Volatile private var indexingState: Indexing? = null
@Volatile private var controller: Controller? = null
@Volatile private var listener: Listener? = null
@ -197,11 +191,11 @@ class Indexer private constructor() {
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
* @return A newly-loaded [MusicStore.Library].
* @return A newly-loaded [Library].
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
* @throws NoMusicException If no music was found on the device.
*/
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library {
private suspend fun indexImpl(context: Context, withCache: Boolean): Library {
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
@ -217,7 +211,6 @@ class Indexer private constructor() {
} else {
WriteOnlyCacheExtractor(context)
}
val mediaStoreExtractor =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
@ -226,33 +219,24 @@ class Indexer private constructor() {
Api29MediaStoreExtractor(context, cacheDatabase)
else -> Api21MediaStoreExtractor(context, cacheDatabase)
}
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val songs =
buildSongs(metadataExtractor, Settings(context)).ifEmpty { throw NoMusicException() }
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs)
val artists = buildArtists(songs, albums)
val genres = buildGenres(songs)
val library = Library(rawSongs, MusicSettings.from(context))
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return MusicStore.Library(songs, albums, artists, genres)
return library
}
/**
* Load a list of [Song]s from the device.
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
* instances.
* @param settings [Settings] required to create [Song] instances.
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
* with parent [Album], [Artist], and [Genre] items in order to be usable.
*/
private suspend fun buildSongs(
metadataExtractor: MetadataExtractor,
settings: Settings
): List<Song> {
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
@ -262,104 +246,23 @@ class Indexer private constructor() {
yield()
// Note: We use a set here so we can eliminate song duplicates.
val songs = mutableSetOf<Song>()
val rawSongs = mutableListOf<Song.Raw>()
metadataExtractor.parse { rawSong ->
songs.add(Song(rawSong, settings))
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have
// loaded, and the projected amount of songs we found in the library
// (obtained by the extractors)
yield()
emitIndexing(Indexing.Songs(songs.size, total))
emitIndexing(Indexing.Songs(rawSongs.size, total))
}
// Finalize the extractors with the songs we have now loaded. There is no ETA
// on this process, so go back to an indeterminate state.
emitIndexing(Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs)
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
// Ensure that sorting order is consistent so that grouping is also consistent.
// Rolling this into the set is not an option, as songs with the same sort result
// would be lost.
return Sort(Sort.Mode.ByName, true).songs(songs)
}
/**
* Build a list of [Album]s from the given [Song]s.
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created.
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
* with parent [Artist] instances in order to be usable.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
// Group songs by their singular raw album, then map the raw instances and their
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it._rawAlbum }
val albums = songsByAlbum.map { Album(it.key, it.value) }
logD("Successfully built ${albums.size} albums")
return albums
}
/**
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
* artist names, and [Album]s being grouped primarily by album artist names.
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s.
*/
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
for (song in songs) {
for (rawArtist in song._rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
}
}
for (album in albums) {
for (rawArtist in album._rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
}
}
// Convert the combined mapping into artist instances.
val artists = musicByArtist.map { Artist(it.key, it.value) }
logD("Successfully built ${artists.size} artists")
return artists
}
/**
* Group up [Song]s into [Genre] instances.
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created.
* @return A non-empty list of [Genre]s.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
for (song in songs) {
for (rawGenre in song._rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
}
}
// Convert the mapping into genre instances.
val genres = songsByGenre.map { Genre(it.key, it.value) }
logD("Successfully built ${genres.size} genres")
return genres
logD(
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
return rawSongs
}
/**
@ -386,7 +289,7 @@ class Indexer private constructor() {
* @param result The new [Result] to emit, representing the outcome of the music loading
* process.
*/
private suspend fun emitCompletion(result: Result<MusicStore.Library>) {
private suspend fun emitCompletion(result: Result<Library>) {
yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
@ -417,7 +320,7 @@ class Indexer private constructor() {
* Music loading has completed.
* @param result The outcome of the music loading process.
*/
data class Complete(val result: Result<MusicStore.Library>) : State()
data class Complete(val result: Result<Library>) : State()
}
/**
@ -455,7 +358,7 @@ class Indexer private constructor() {
*
* This is only useful for code that absolutely must show the current loading process.
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
* the [MusicStore.Library].
* the [Library].
*/
interface Listener {
/**

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.system
import android.app.Service
import android.content.Intent
import android.content.SharedPreferences
import android.database.ContentObserver
import android.os.Handler
import android.os.IBinder
@ -32,12 +31,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
@ -55,8 +53,7 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexerService :
Service(), Indexer.Controller, SharedPreferences.OnSharedPreferenceChangeListener {
class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
@ -68,7 +65,7 @@ class IndexerService :
private lateinit var observingNotification: ObservingNotification
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver
private lateinit var settings: Settings
private lateinit var settings: MusicSettings
override fun onCreate() {
super.onCreate()
@ -83,8 +80,8 @@ class IndexerService :
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
settings = Settings(this)
settings.addListener(this)
settings = MusicSettings.from(this)
settings.registerListener(this)
indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music.
@ -108,7 +105,7 @@ class IndexerService :
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
settings.removeListener(this)
settings.unregisterListener(this)
indexer.unregisterController(this)
// Then cancel any remaining music loading jobs.
serviceJob.cancel()
@ -230,22 +227,18 @@ class IndexerService :
// --- SETTING CALLBACKS ---
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
// Hook changes in music settings to a new music loading event.
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators) -> onStartIndexing(true)
getString(R.string.set_key_observing) -> {
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (!indexer.isIndexing) {
updateIdleSession()
}
}
override fun onIndexingSettingChanged() {
// Music loading configuration changed, need to reload music.
onStartIndexing(true)
}
override fun onObservingChanged() {
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (!indexer.isIndexing) {
updateIdleSession()
}
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
package org.oxycblt.auxio.music.tags
import android.content.Context
import java.text.ParseException
@ -74,7 +74,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
override fun hashCode() = tokens.hashCode()
override fun equals(other: Any?) = other is Date && tokens == other.tokens
override fun equals(other: Any?) = other is Date && compareTo(other) == 0
override fun compareTo(other: Date): Int {
for (i in 0 until max(tokens.size, other.tokens.size)) {
@ -140,8 +140,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
min.resolveDate(context)
}
override fun equals(other: Any?) =
other is Range && min == other.min && max == other.max
override fun equals(other: Any?) = other is Range && min == other.min && max == other.max
override fun hashCode() = 31 * max.hashCode() + min.hashCode()
@ -183,14 +182,25 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
*/
private val ISO8601_REGEX =
Regex(
"""^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
"""^(\d{4})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""")
/**
* Create a [Date] from a year component.
* @param year The year component.
* @return A new [Date] of the given component, or null if the component is invalid.
*/
fun from(year: Int) = fromTokens(listOf(year))
fun from(year: Int) =
if (year in 10000000..100000000) {
// Year is actually more likely to be a separated date timestamp. Interpret
// it as such.
val stringYear = year.toString()
from(
stringYear.substring(0..3).toInt(),
stringYear.substring(4..5).toInt(),
stringYear.substring(6..7).toInt())
} else {
fromTokens(listOf(year))
}
/**
* Create a [Date] from a date component.
@ -223,8 +233,10 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
*/
fun from(timestamp: String): Date? {
val tokens =
// Match the input with the timestamp regex
(ISO8601_REGEX.matchEntire(timestamp) ?: return null)
// Match the input with the timestamp regex. If there is no match, see if we can
// fall back to some kind of year value.
(ISO8601_REGEX.matchEntire(timestamp)
?: return timestamp.toIntOrNull()?.let(Companion::from))
.groupValues
// Filter to the specific tokens we want and convert them to integer tokens.
.mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null }
@ -239,7 +251,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
*/
private fun fromTokens(tokens: List<Int>): Date? {
val validated = mutableListOf<Int>()
validateTokens(tokens, validated)
transformTokens(tokens, validated)
if (validated.isEmpty()) {
// No token was valid, return null.
return null
@ -253,7 +265,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
* @param src The input tokens to validate.
* @param dst The destination list to add valid tokens to.
*/
private fun validateTokens(src: List<Int>, dst: MutableList<Int>) {
private fun transformTokens(src: List<Int>, dst: MutableList<Int>) {
dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)

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.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
@ -65,8 +64,8 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding.playbackInfo.isSelected = true
// Set up actions
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
setupSecondaryActions(binding, Settings(context))
binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() }
setupSecondaryActions(binding, playbackModel.currentBarAction)
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources.
@ -86,8 +85,8 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding.playbackInfo.isSelected = false
}
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
when (settings.playbackBarAction) {
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, actionMode: ActionMode) {
when (actionMode) {
ActionMode.NEXT -> {
binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.ic_skip_next_24)
@ -109,7 +108,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
setIconResource(R.drawable.sel_shuffle_state_24)
contentDescription = getString(R.string.desc_shuffle)
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.invertShuffled() }
setOnClickListener { playbackModel.toggleShuffled() }
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
}
}

View file

@ -105,9 +105,9 @@ class PlaybackPanelFragment :
// TODO: Add better playback button accessibility
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
binding.playbackPlayPause.setOnClickListener { playbackModel.togglePlaying() }
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
binding.playbackShuffle.setOnClickListener { playbackModel.toggleShuffled() }
// --- VIEWMODEL SETUP --
collectImmediately(playbackModel.song, ::updateSong)

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.launch
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.playback.state.*
import org.oxycblt.auxio.util.context
/**
@ -39,8 +35,10 @@ import org.oxycblt.auxio.util.context
*/
class PlaybackViewModel(application: Application) :
AndroidViewModel(application), PlaybackStateManager.Listener {
private val settings = Settings(application)
private val musicSettings = MusicSettings.from(application)
private val playbackSettings = PlaybackSettings.from(application)
private val playbackManager = PlaybackStateManager.getInstance()
private val musicStore = MusicStore.getInstance()
private var lastPositionJob: Job? = null
private val _song = MutableStateFlow<Song?>(null)
@ -85,6 +83,10 @@ class PlaybackViewModel(application: Application) :
val genrePickerSong: StateFlow<Song?>
get() = _genrePlaybackPickerSong
/** The current action to show on the playback bar. */
val currentBarAction: ActionMode
get() = playbackSettings.barAction
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
@ -100,13 +102,25 @@ class PlaybackViewModel(application: Application) :
playbackManager.removeListener(this)
}
override fun onIndexMoved(index: Int) {
_song.value = playbackManager.song
override fun onIndexMoved(queue: Queue) {
_song.value = queue.currentSong
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
_song.value = playbackManager.song
_parent.value = playbackManager.parent
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
// Other types of queue changes preserve the current song.
if (change == Queue.ChangeResult.SONG) {
_song.value = queue.currentSong
}
}
override fun onQueueReordered(queue: Queue) {
_isShuffled.value = queue.isShuffled
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
_song.value = queue.currentSong
_parent.value = parent
_isShuffled.value = queue.isShuffled
}
override fun onStateChanged(state: InternalPlayer.State) {
@ -126,35 +140,33 @@ class PlaybackViewModel(application: Application) :
}
}
override fun onShuffledChanged(isShuffled: Boolean) {
_isShuffled.value = isShuffled
}
override fun onRepeatChanged(repeatMode: RepeatMode) {
_repeatMode.value = repeatMode
}
// --- PLAYING FUNCTIONS ---
/**
* Play the given [Song] from all songs in the music library.
* @param song The [Song] to play.
*/
fun playFromAll(song: Song) {
playbackManager.play(song, null, settings)
}
/** Shuffle all songs in the music library. */
fun shuffleAll() {
playbackManager.play(null, null, settings, true)
playImpl(null, null, true)
}
/**
* Play a [Song] from it's [Album].
* Play a [Song] from the [MusicParent] outlined by the given [MusicMode].
* - If [MusicMode.SONGS], the [Song] is played from all songs.
* - If [MusicMode.ALBUMS], the [Song] is played from it's [Album].
* - If [MusicMode.ARTISTS], the [Song] is played from one of it's [Artist]s.
* - If [MusicMode.GENRES], the [Song] is played from one of it's [Genre]s.
* @param song The [Song] to play.
* @param playbackMode The [MusicMode] to play from.
*/
fun playFromAlbum(song: Song) {
playbackManager.play(song, song.album, settings)
fun playFrom(song: Song, playbackMode: MusicMode) {
when (playbackMode) {
MusicMode.SONGS -> playImpl(song, null)
MusicMode.ALBUMS -> playImpl(song, song.album)
MusicMode.ARTISTS -> playFromArtist(song)
MusicMode.GENRES -> playFromGenre(song)
}
}
/**
@ -165,10 +177,9 @@ class PlaybackViewModel(application: Application) :
*/
fun playFromArtist(song: Song, artist: Artist? = null) {
if (artist != null) {
check(artist in song.artists) { "Artist not in song artists" }
playbackManager.play(song, artist, settings)
playImpl(song, artist)
} else if (song.artists.size == 1) {
playbackManager.play(song, song.artists[0], settings)
playImpl(song, song.artists[0])
} else {
_artistPlaybackPickerSong.value = song
}
@ -191,61 +202,91 @@ class PlaybackViewModel(application: Application) :
*/
fun playFromGenre(song: Song, genre: Genre? = null) {
if (genre != null) {
check(genre.songs.contains(song)) { "Invalid input: Genre is not linked to song" }
playbackManager.play(song, genre, settings)
playImpl(song, genre)
} else if (song.genres.size == 1) {
playbackManager.play(song, song.genres[0], settings)
playImpl(song, song.genres[0])
} else {
_genrePlaybackPickerSong.value = song
}
}
/**
* Mark the [Genre] playback choice process as complete. This should occur when the [Genre]
* choice dialog is opened after this flag is detected.
* @see playFromGenre
*/
fun finishPlaybackGenrePicker() {
_genrePlaybackPickerSong.value = null
}
/**
* Play an [Album].
* @param album The [Album] to play.
*/
fun play(album: Album) {
playbackManager.play(null, album, settings, false)
}
fun play(album: Album) = playImpl(null, album, false)
/**
* Play an [Artist].
* @param artist The [Artist] to play.
*/
fun play(artist: Artist) {
playbackManager.play(null, artist, settings, false)
}
fun play(artist: Artist) = playImpl(null, artist, false)
/**
* Play a [Genre].
* @param genre The [Genre] to play.
*/
fun play(genre: Genre) {
playbackManager.play(null, genre, settings, false)
}
fun play(genre: Genre) = playImpl(null, genre, false)
/**
* Play a [Music] selection.
* @param selection The selection to play.
*/
fun play(selection: List<Music>) =
playbackManager.play(null, null, selectionToSongs(selection), false)
/**
* Shuffle an [Album].
* @param album The [Album] to shuffle.
*/
fun shuffle(album: Album) {
playbackManager.play(null, album, settings, true)
}
fun shuffle(album: Album) = playImpl(null, album, true)
/**
* Shuffle an [Artist].
* @param artist The [Artist] to shuffle.
*/
fun shuffle(artist: Artist) {
playbackManager.play(null, artist, settings, true)
}
fun shuffle(artist: Artist) = playImpl(null, artist, true)
/**
* Shuffle an [Genre].
* @param genre The [Genre] to shuffle.
*/
fun shuffle(genre: Genre) {
playbackManager.play(null, genre, settings, true)
fun shuffle(genre: Genre) = playImpl(null, genre, true)
/**
* Shuffle a [Music] selection.
* @param selection The selection to shuffle.
*/
fun shuffle(selection: List<Music>) =
playbackManager.play(null, null, selectionToSongs(selection), true)
private fun playImpl(
song: Song?,
parent: MusicParent?,
shuffled: Boolean = playbackManager.queue.isShuffled && playbackSettings.keepShuffle
) {
check(song == null || parent == null || parent.songs.contains(song)) {
"Song to play not in parent"
}
val library = musicStore.library ?: return
val sort =
when (parent) {
is Genre -> musicSettings.genreSongSort
is Artist -> musicSettings.artistSongSort
is Album -> musicSettings.albumSongSort
null -> musicSettings.songSort
}
val queue = sort.songs(parent?.songs ?: library.songs)
playbackManager.play(song, parent, queue, shuffled)
}
/**
@ -284,8 +325,6 @@ class PlaybackViewModel(application: Application) :
* @param song The [Song] to add.
*/
fun playNext(song: Song) {
// TODO: Queue additions without a playing song should map to playing items
// (impossible until queue rework)
playbackManager.playNext(song)
}
@ -294,7 +333,7 @@ class PlaybackViewModel(application: Application) :
* @param album The [Album] to add.
*/
fun playNext(album: Album) {
playbackManager.playNext(settings.detailAlbumSort.songs(album.songs))
playbackManager.playNext(musicSettings.albumSongSort.songs(album.songs))
}
/**
@ -302,7 +341,7 @@ class PlaybackViewModel(application: Application) :
* @param artist The [Artist] to add.
*/
fun playNext(artist: Artist) {
playbackManager.playNext(settings.detailArtistSort.songs(artist.songs))
playbackManager.playNext(musicSettings.artistSongSort.songs(artist.songs))
}
/**
@ -310,7 +349,7 @@ class PlaybackViewModel(application: Application) :
* @param genre The [Genre] to add.
*/
fun playNext(genre: Genre) {
playbackManager.playNext(settings.detailGenreSort.songs(genre.songs))
playbackManager.playNext(musicSettings.genreSongSort.songs(genre.songs))
}
/**
@ -334,7 +373,7 @@ class PlaybackViewModel(application: Application) :
* @param album The [Album] to add.
*/
fun addToQueue(album: Album) {
playbackManager.addToQueue(settings.detailAlbumSort.songs(album.songs))
playbackManager.addToQueue(musicSettings.albumSongSort.songs(album.songs))
}
/**
@ -342,7 +381,7 @@ class PlaybackViewModel(application: Application) :
* @param artist The [Artist] to add.
*/
fun addToQueue(artist: Artist) {
playbackManager.addToQueue(settings.detailArtistSort.songs(artist.songs))
playbackManager.addToQueue(musicSettings.artistSongSort.songs(artist.songs))
}
/**
@ -350,7 +389,7 @@ class PlaybackViewModel(application: Application) :
* @param genre The [Genre] to add.
*/
fun addToQueue(genre: Genre) {
playbackManager.addToQueue(settings.detailGenreSort.songs(genre.songs))
playbackManager.addToQueue(musicSettings.genreSongSort.songs(genre.songs))
}
/**
@ -364,13 +403,13 @@ class PlaybackViewModel(application: Application) :
// --- STATUS FUNCTIONS ---
/** Toggle [isPlaying] (i.e from playing to paused) */
fun toggleIsPlaying() {
fun togglePlaying() {
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
}
/** Toggle [isShuffled] (ex. from on to off) */
fun invertShuffled() {
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
fun toggleShuffled() {
playbackManager.reorder(!playbackManager.queue.isShuffled)
}
/**
@ -427,9 +466,9 @@ class PlaybackViewModel(application: Application) :
private fun selectionToSongs(selection: List<Music>): List<Song> {
return selection.flatMap {
when (it) {
is Album -> settings.detailAlbumSort.songs(it.songs)
is Artist -> settings.detailArtistSort.songs(it.songs)
is Genre -> settings.detailGenreSort.songs(it.songs)
is Album -> musicSettings.albumSongSort.songs(it.songs)
is Artist -> musicSettings.artistSongSort.songs(it.songs)
is Genre -> musicSettings.genreSongSort.songs(it.songs)
is Song -> listOf(it)
}
}

View file

@ -27,31 +27,28 @@ import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.recycler.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.DiffAdapter
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.*
/**
* A [RecyclerView.Adapter] that shows an editable list of queue items.
* @param listener A [EditableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueAdapter(private val listener: EditableListListener) :
RecyclerView.Adapter<QueueSongViewHolder>() {
private var differ = SyncListDiffer(this, QueueSongViewHolder.DIFF_CALLBACK)
class QueueAdapter(private val listener: EditableListListener<Song>) :
DiffAdapter<Song, BasicListInstructions, QueueSongViewHolder>(
ListDiffer.Blocking(QueueSongViewHolder.DIFF_CALLBACK)) {
// Since PlayingIndicator adapter relies on an item value, we cannot use it for this
// adapter, as one item can appear at several points in the UI. Use a similar implementation
// with an index value instead.
private var currentIndex = 0
private var isPlaying = false
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
QueueSongViewHolder.from(parent)
@ -64,31 +61,13 @@ class QueueAdapter(private val listener: EditableListListener) :
payload: List<Any>
) {
if (payload.isEmpty()) {
viewHolder.bind(differ.currentList[position], listener)
viewHolder.bind(getItem(position), listener)
}
viewHolder.isFuture = position > currentIndex
viewHolder.updatePlayingIndicator(position == currentIndex, isPlaying)
}
/**
* Synchronously update the list with new items. This is exceedingly slow for large diffs, so
* only use it for trivial updates.
* @param newList The new [Song]s for the adapter to display.
*/
fun submitList(newList: List<Song>) {
differ.submitList(newList)
}
/**
* Replace the list with a new list. This is exceedingly slow for large diffs, so only use it
* for trivial updates.
* @param newList The new [Song]s for the adapter to display.
*/
fun replaceList(newList: List<Song>) {
differ.replaceList(newList)
}
/**
* Set the position of the currently playing item in the queue. This will mark the item as
* playing and any previous items as played.
@ -96,30 +75,19 @@ class QueueAdapter(private val listener: EditableListListener) :
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPosition(index: Int, isPlaying: Boolean) {
var updatedIndex = false
logD("Updating index")
val lastIndex = currentIndex
currentIndex = index
if (index != currentIndex) {
val lastIndex = currentIndex
currentIndex = index
updatedIndex = true
// Have to update not only the currently playing item, but also all items marked
// as playing.
if (currentIndex < lastIndex) {
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION)
} else {
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION)
}
// Have to update not only the currently playing item, but also all items marked
// as playing.
if (currentIndex < lastIndex) {
notifyItemRangeChanged(0, lastIndex + 1, PAYLOAD_UPDATE_POSITION)
} else {
notifyItemRangeChanged(0, currentIndex + 1, PAYLOAD_UPDATE_POSITION)
}
if (this.isPlaying != isPlaying) {
this.isPlaying = isPlaying
// Don't need to do anything if we've already sent an update from changing the
// index.
if (!updatedIndex) {
notifyItemChanged(index, PAYLOAD_UPDATE_POSITION)
}
}
this.isPlaying = isPlaying
}
private companion object {
@ -158,7 +126,6 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
binding.songAlbumCover.isEnabled = value
binding.songName.isEnabled = value
binding.songInfo.isEnabled = value
binding.songDragHandle.isEnabled = value
}
init {
@ -178,7 +145,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
* @param listener A [EditableListListener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(song: Song, listener: EditableListListener) {
fun bind(song: Song, listener: EditableListListener<Song>) {
listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
@ -202,6 +169,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
fun from(parent: View) =
QueueSongViewHolder(ItemQueueSongBinding.inflate(parent.context.inflater))
// TODO: This is not good enough, I need to compare item indices as well.
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = SongViewHolder.DIFF_CALLBACK
}

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,
* such as an animation when lifting items.
*
* TODO: Why is item movement so expensive???
*
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueDragCallback(private val playbackModel: QueueViewModel) : ItemTouchHelper.Callback() {
private var shouldLift = true
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val queueHolder = viewHolder as QueueSongViewHolder
return if (queueHolder.isFuture) {
makeFlag(
ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
} else {
// Avoid allowing any touch actions for already-played queue items, as the playback
// system does not currently allow for this.
0
}
}
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) =
makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) or
makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.START)
override fun onChildDraw(
c: Canvas,

View file

@ -27,19 +27,18 @@ import androidx.recyclerview.widget.RecyclerView
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
/**
* A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt)
*/
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener {
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val queueAdapter = QueueAdapter(this)
@ -78,10 +77,11 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
override fun onDestroyBinding(binding: FragmentQueueBinding) {
super.onDestroyBinding(binding)
touchHelper = null
binding.queueRecycler.adapter = null
}
override fun onClick(item: Item, viewHolder: RecyclerView.ViewHolder) {
override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) {
queueModel.goto(viewHolder.bindingAdapterPosition)
}
@ -100,18 +100,13 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
val binding = requireBinding()
// Replace or diff the queue depending on the type of change it is.
// TODO: Extend this to the whole app.
if (queueModel.replaceQueue == true) {
logD("Replacing queue")
queueAdapter.replaceList(queue)
} else {
logD("Diffing queue")
queueAdapter.submitList(queue)
}
queueModel.finishReplace()
val instructions = queueModel.queueListInstructions
queueAdapter.submitList(queue, instructions?.update ?: BasicListInstructions.DIFF)
// Update position in list (and thus past/future items)
queueAdapter.setPosition(index, isPlaying)
// If requested, scroll to a new item (occurs when the index moves)
val scrollTo = queueModel.scrollTo
val scrollTo = instructions?.scrollTo
if (scrollTo != null) {
val lmm = binding.queueRecycler.layoutManager as LinearLayoutManager
val start = lmm.findFirstCompletelyVisibleItemPosition()
@ -126,15 +121,13 @@ class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListL
binding.queueRecycler.scrollToPosition(scrollTo)
} else if (scrollTo > end) {
// We need to scroll downwards, we need to offset by a screen of songs.
// This does have some error due to what the layout manager returns being
// somewhat mutable. This is considered okay.
// This does have some error due to how many completely visible items on-screen
// can vary. This is considered okay.
binding.queueRecycler.scrollToPosition(
min(queue.lastIndex, scrollTo + (end - start)))
}
}
queueModel.finishScrollTo()
// Update position in list (and thus past/future items)
queueAdapter.setPosition(index, isPlaying)
queueModel.finishInstructions()
}
}

View file

@ -20,9 +20,11 @@ package org.oxycblt.auxio.playback.queue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
/**
* A [ViewModel] that manages the current queue state and allows navigation through the queue.
@ -36,30 +38,58 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
/** The current queue. */
val queue: StateFlow<List<Song>> = _queue
private val _index = MutableStateFlow(playbackManager.index)
private val _index = MutableStateFlow(playbackManager.queue.index)
/** The index of the currently playing song in the queue. */
val index: StateFlow<Int>
get() = _index
/** Whether to replace or diff the queue list when updating it. Is null if not specified. */
var replaceQueue: Boolean? = null
/** Flag to scroll to a particular queue item. Is null if no command has been specified. */
var scrollTo: Int? = null
/** Specifies how to update the list when the queue changes. */
var queueListInstructions: ListInstructions? = null
init {
playbackManager.addListener(this)
}
override fun onIndexMoved(queue: Queue) {
queueListInstructions = ListInstructions(null, queue.index)
_index.value = queue.index
}
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
// Queue changed trivially due to item mo -> Diff queue, stay at current index.
queueListInstructions = ListInstructions(BasicListInstructions.DIFF, null)
_queue.value = queue.resolve()
if (change != Queue.ChangeResult.MAPPING) {
// Index changed, make sure it remains updated without actually scrolling to it.
_index.value = queue.index
}
}
override fun onQueueReordered(queue: Queue) {
// Queue changed completely -> Replace queue, update index
queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
queueListInstructions = ListInstructions(BasicListInstructions.REPLACE, queue.index)
_queue.value = queue.resolve()
_index.value = queue.index
}
override fun onCleared() {
super.onCleared()
playbackManager.removeListener(this)
}
/**
* Start playing the the queue item at the given index.
* @param adapterIndex The index of the queue item to play. Does nothing if the index is out of
* range.
*/
fun goto(adapterIndex: Int) {
if (adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
return
}
playbackManager.goto(adapterIndex)
}
@ -69,10 +99,7 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
* range.
*/
fun removeQueueDataItem(adapterIndex: Int) {
if (adapterIndex <= playbackManager.index ||
adapterIndex !in playbackManager.queue.indices) {
// Invalid input. Nothing to do.
// TODO: Allow editing played queue items.
if (adapterIndex !in queue.value.indices) {
return
}
playbackManager.removeQueueItem(adapterIndex)
@ -85,56 +112,17 @@ class QueueViewModel : ViewModel(), PlaybackStateManager.Listener {
* @return true if the items were moved, false otherwise.
*/
fun moveQueueDataItems(adapterFrom: Int, adapterTo: Int): Boolean {
if (adapterFrom <= playbackManager.index || adapterTo <= playbackManager.index) {
// Invalid input. Nothing to do.
if (adapterFrom !in queue.value.indices || adapterTo !in queue.value.indices) {
return false
}
playbackManager.moveQueueItem(adapterFrom, adapterTo)
return true
}
/** Finish a replace flag specified by [replaceQueue]. */
fun finishReplace() {
replaceQueue = null
/** Signal that the specified [ListInstructions] in [queueListInstructions] were performed. */
fun finishInstructions() {
queueListInstructions = null
}
/** Finish a scroll operation started by [scrollTo]. */
fun finishScrollTo() {
scrollTo = null
}
override fun onIndexMoved(index: Int) {
// Index moved -> Scroll to new index
replaceQueue = null
scrollTo = index
_index.value = index
}
override fun onQueueChanged(queue: List<Song>) {
// Queue changed trivially due to item move -> Diff queue, stay at current index.
replaceQueue = false
scrollTo = null
_queue.value = playbackManager.queue.toMutableList()
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
// Queue changed completely -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()
_index.value = index
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
// Entirely new queue -> Replace queue, update index
replaceQueue = true
scrollTo = index
_queue.value = playbackManager.queue.toMutableList()
_index.value = index
}
override fun onCleared() {
super.onCleared()
playbackManager.removeListener(this)
}
class ListInstructions(val update: BasicListInstructions?, val scrollTo: Int?)
}

View file

@ -24,7 +24,7 @@ import androidx.appcompat.app.AlertDialog
import kotlin.math.abs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPreAmpBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
/**
@ -39,11 +39,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
.setTitle(R.string.set_pre_amp)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
val binding = requireBinding()
Settings(requireContext()).replayGainPreAmp =
PlaybackSettings.from(requireContext()).replayGainPreAmp =
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
}
.setNeutralButton(R.string.lbl_reset) { _, _ ->
Settings(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
}
.setNegativeButton(R.string.lbl_cancel, null)
}
@ -53,7 +53,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
// First initialization, we need to supply the sliders with the values from
// settings. After this, the sliders save their own state, so we do not need to
// do any restore behavior.
val preAmp = Settings(requireContext()).replayGainPreAmp
val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp
binding.withTagsSlider.value = preAmp.with
binding.withoutTagsSlider.value = preAmp.without
}

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.playback.replaygain
import android.content.Context
import android.content.SharedPreferences
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.Player
@ -28,11 +27,10 @@ import com.google.android.exoplayer2.audio.BaseAudioProcessor
import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer
import kotlin.math.pow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.extractor.Tags
import org.oxycblt.auxio.music.extractor.TextTags
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
/**
@ -45,10 +43,10 @@ import org.oxycblt.auxio.util.logD
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ReplayGainAudioProcessor(private val context: Context) :
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
class ReplayGainAudioProcessor(context: Context) :
BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener {
private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context)
private val playbackSettings = PlaybackSettings.from(context)
private var lastFormat: Format? = null
private var volume = 1f
@ -65,7 +63,7 @@ class ReplayGainAudioProcessor(private val context: Context) :
*/
fun addToListeners(player: Player) {
player.addListener(this)
settings.addListener(this)
playbackSettings.registerListener(this)
}
/**
@ -75,7 +73,7 @@ class ReplayGainAudioProcessor(private val context: Context) :
*/
fun releaseFromListeners(player: Player) {
player.removeListener(this)
settings.removeListener(this)
playbackSettings.unregisterListener(this)
}
// --- OVERRIDES ---
@ -98,13 +96,9 @@ class ReplayGainAudioProcessor(private val context: Context) :
applyReplayGain(null)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == context.getString(R.string.set_key_replay_gain) ||
key == context.getString(R.string.set_key_pre_amp_with) ||
key == context.getString(R.string.set_key_pre_amp_without)) {
// ReplayGain changed, we need to set it up again.
applyReplayGain(lastFormat)
}
override fun onReplayGainSettingsChanged() {
// ReplayGain config changed, we need to set it up again.
applyReplayGain(lastFormat)
}
// --- REPLAYGAIN PARSING ---
@ -116,26 +110,24 @@ class ReplayGainAudioProcessor(private val context: Context) :
private fun applyReplayGain(format: Format?) {
lastFormat = format
val gain = parseReplayGain(format ?: return)
val preAmp = settings.replayGainPreAmp
val preAmp = playbackSettings.replayGainPreAmp
val adjust =
if (gain != null) {
logD("Found ReplayGain adjustment $gain")
// ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain =
when (settings.replayGainMode) {
when (playbackSettings.replayGainMode) {
// User wants track gain to be preferred. Default to album gain only if
// there is no track gain.
ReplayGainMode.TRACK -> gain.track == 0f
// User wants album gain to be preferred. Default to track gain only if
// here is no album gain.
ReplayGainMode.ALBUM -> gain.album != 0f
// User wants album gain to be used when in an album, track gain otherwise.
ReplayGainMode.DYNAMIC ->
playbackManager.parent is Album &&
playbackManager.song?.album == playbackManager.parent
playbackManager.queue.currentSong?.album == playbackManager.parent
}
val resolvedGain =
@ -168,35 +160,35 @@ class ReplayGainAudioProcessor(private val context: Context) :
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
*/
private fun parseReplayGain(format: Format): Adjustment? {
val tags = Tags(format.metadata ?: return null)
val textTags = TextTags(format.metadata ?: return null)
var trackGain = 0f
var albumGain = 0f
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
// replaygain_*_gain tag.
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
tags.vorbis[TAG_RG_ALBUM_GAIN]
textTags.vorbis[TAG_RG_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.vorbis[TAG_RG_TRACK_GAIN]
textTags.vorbis[TAG_RG_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
} else {
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
// adjustment by 256 to get the gain. This is used alongside the base adjustment
// intrinsic to the format to create the normalized adjustment. That base adjustment
// is already handled by the media framework, so we just need to apply the more
// is already handled by the media framework, so we just need to apply the more
// specific adjustments.
tags.vorbis[TAG_R128_TRACK_GAIN]
textTags.vorbis[TAG_R128_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it / 256f }
tags.vorbis[TAG_R128_ALBUM_GAIN]
textTags.vorbis[TAG_R128_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it / 256f }
}
@ -231,27 +223,32 @@ class ReplayGainAudioProcessor(private val context: Context) :
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
}
override fun isActive() = super.isActive() && volume != 1f
override fun queueInput(inputBuffer: ByteBuffer) {
val position = inputBuffer.position()
val pos = inputBuffer.position()
val limit = inputBuffer.limit()
val size = limit - position
val buffer = replaceOutputBuffer(size)
val buffer = replaceOutputBuffer(limit - pos)
for (i in position until limit step 2) {
// Ensure we clamp the values to the minimum and maximum values possible
// for the encoding. This prevents issues where samples amplified beyond
// 1 << 16 will end up becoming truncated during the conversion to a short,
// resulting in popping.
var sample = inputBuffer.getLeShort(i)
sample =
(sample * volume)
.toInt()
.coerceAtLeast(Short.MIN_VALUE.toInt())
.coerceAtMost(Short.MAX_VALUE.toInt())
.toShort()
buffer.putLeShort(sample)
if (volume == 1f) {
// Nothing to adjust, just copy the audio data.
// isActive is technically a much better way of doing a no-op like this, but since
// the adjustment can change during playback I'm largely forced to do this.
buffer.put(inputBuffer.slice())
} else {
for (i in pos until limit step 2) {
// 16-bit PCM audio, deserialize a little-endian short.
var sample = inputBuffer.getLeShort(i)
// Ensure we clamp the values to the minimum and maximum values possible
// for the encoding. This prevents issues where samples amplified beyond
// 1 << 16 will end up becoming truncated during the conversion to a short,
// resulting in popping.
sample =
(sample * volume)
.toInt()
.coerceAtLeast(Short.MIN_VALUE.toInt())
.coerceAtMost(Short.MAX_VALUE.toInt())
.toShort()
buffer.putLeShort(sample)
}
}
inputBuffer.position(limit)

View file

@ -22,11 +22,10 @@ import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.provider.BaseColumns
import androidx.core.database.getIntOrNull
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.util.*
/**
@ -42,17 +41,22 @@ class PlaybackStateDatabase private constructor(context: Context) :
// of the non-queue parts of the state, such as the playback position.
db.createTable(TABLE_STATE) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${StateColumns.INDEX} INTEGER NOT NULL,")
append("${StateColumns.POSITION} LONG NOT NULL,")
append("${StateColumns.REPEAT_MODE} INTEGER NOT NULL,")
append("${StateColumns.IS_SHUFFLED} BOOLEAN NOT NULL,")
append("${StateColumns.SONG_UID} STRING,")
append("${StateColumns.PARENT_UID} STRING")
append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,")
append("${PlaybackStateColumns.POSITION} LONG NOT NULL,")
append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,")
append("${PlaybackStateColumns.SONG_UID} STRING,")
append("${PlaybackStateColumns.PARENT_UID} STRING")
}
db.createTable(TABLE_QUEUE) {
db.createTable(TABLE_QUEUE_HEAP) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${QueueColumns.SONG_UID} STRING NOT NULL")
append("${QueueHeapColumns.SONG_UID} STRING NOT NULL")
}
db.createTable(TABLE_QUEUE_MAPPINGS) {
append("${BaseColumns._ID} INTEGER PRIMARY KEY,")
append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,")
append("${QueueMappingColumns.SHUFFLED_INDEX} INT")
}
}
@ -63,7 +67,8 @@ class PlaybackStateDatabase private constructor(context: Context) :
logD("Nuking database")
db.apply {
execSQL("DROP TABLE IF EXISTS $TABLE_STATE")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP")
execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS")
onCreate(this)
}
}
@ -72,70 +77,85 @@ class PlaybackStateDatabase private constructor(context: Context) :
/**
* Read a persisted [SavedState] from the database.
* @param library [MusicStore.Library] required to restore [SavedState].
* @param library [Library] required to restore [SavedState].
* @return A persisted [SavedState], or null if one could not be found.
*/
fun read(library: MusicStore.Library): SavedState? {
fun read(library: Library): SavedState? {
requireBackgroundThread()
// Read the saved state and queue. If the state is non-null, that must imply an
// existent, albeit possibly empty, queue.
val rawState = readRawState() ?: return null
val queue = readQueue(library)
// Correct the index to match up with a queue that has possibly been shortened due to
// song removals.
var actualIndex = rawState.index
while (queue.getOrNull(actualIndex)?.uid != rawState.songUid && actualIndex > -1) {
actualIndex--
}
val rawState = readRawPlaybackState() ?: return null
val rawQueueState = readRawQueueState(library)
// Restore parent item from the music library. If this fails, then the playback mode
// reverts to "All Songs", which is considered okay.
val parent = rawState.parentUid?.let { library.find<MusicParent>(it) }
return SavedState(
index = actualIndex,
parent = parent,
queue = queue,
queueState =
Queue.SavedState(
heap = rawQueueState.heap,
orderedMapping = rawQueueState.orderedMapping,
shuffledMapping = rawQueueState.shuffledMapping,
index = rawState.index,
songUid = rawState.songUid),
positionMs = rawState.positionMs,
repeatMode = rawState.repeatMode,
isShuffled = rawState.isShuffled)
repeatMode = rawState.repeatMode)
}
private fun readRawState() =
private fun readRawPlaybackState() =
readableDatabase.queryAll(TABLE_STATE) { cursor ->
if (!cursor.moveToFirst()) {
// Empty, nothing to do.
return@queryAll null
}
val indexIndex = cursor.getColumnIndexOrThrow(StateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(StateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(StateColumns.REPEAT_MODE)
val shuffleIndex = cursor.getColumnIndexOrThrow(StateColumns.IS_SHUFFLED)
val songUidIndex = cursor.getColumnIndexOrThrow(StateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(StateColumns.PARENT_UID)
RawState(
val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX)
val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION)
val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE)
val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID)
val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID)
RawPlaybackState(
index = cursor.getInt(indexIndex),
positionMs = cursor.getLong(posIndex),
repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex))
?: RepeatMode.NONE,
isShuffled = cursor.getInt(shuffleIndex) == 1,
songUid = Music.UID.fromString(cursor.getString(songUidIndex))
?: return@queryAll null,
parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString))
}
private fun readQueue(library: MusicStore.Library): List<Song> {
val queue = mutableListOf<Song>()
readableDatabase.queryAll(TABLE_QUEUE) { cursor ->
val songIndex = cursor.getColumnIndexOrThrow(QueueColumns.SONG_UID)
private fun readRawQueueState(library: Library): RawQueueState {
val heap = mutableListOf<Song?>()
readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor ->
if (cursor.count == 0) {
// Empty, nothing to do.
return@queryAll
}
val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID)
while (cursor.moveToNext()) {
val uid = Music.UID.fromString(cursor.getString(songIndex)) ?: continue
val song = library.find<Song>(uid) ?: continue
queue.add(song)
heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find))
}
}
logD("Successfully read queue of ${heap.size} songs")
val orderedMapping = mutableListOf<Int?>()
val shuffledMapping = mutableListOf<Int?>()
readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor ->
if (cursor.count == 0) {
// Empty, nothing to do.
return@queryAll
}
val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX)
val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX)
while (cursor.moveToNext()) {
orderedMapping.add(cursor.getInt(orderedIndex))
cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add)
}
}
logD("Successfully read queue of ${queue.size} songs")
return queue
return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull())
}
/**
@ -145,41 +165,44 @@ class PlaybackStateDatabase private constructor(context: Context) :
fun write(state: SavedState?) {
requireBackgroundThread()
// Only bother saving a state if a song is actively playing from one.
// This is not the case with a null state or a state with an out-of-bounds index.
if (state != null && state.index in state.queue.indices) {
// This is not the case with a null state.
if (state != null) {
// Transform saved state into raw state, which can then be written to the database.
val rawState =
RawState(
index = state.index,
val rawPlaybackState =
RawPlaybackState(
index = state.queueState.index,
positionMs = state.positionMs,
repeatMode = state.repeatMode,
isShuffled = state.isShuffled,
songUid = state.queue[state.index].uid,
songUid = state.queueState.songUid,
parentUid = state.parent?.uid)
writeRawState(rawState)
writeQueue(state.queue)
writeRawPlaybackState(rawPlaybackState)
val rawQueueState =
RawQueueState(
heap = state.queueState.heap,
orderedMapping = state.queueState.orderedMapping,
shuffledMapping = state.queueState.shuffledMapping)
writeRawQueueState(rawQueueState)
logD("Wrote state")
} else {
writeRawState(null)
writeQueue(null)
writeRawPlaybackState(null)
writeRawQueueState(null)
logD("Cleared state")
}
}
private fun writeRawState(rawState: RawState?) {
private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) {
writableDatabase.transaction {
delete(TABLE_STATE, null, null)
if (rawState != null) {
if (rawPlaybackState != null) {
val stateData =
ContentValues(7).apply {
put(BaseColumns._ID, 0)
put(StateColumns.SONG_UID, rawState.songUid.toString())
put(StateColumns.POSITION, rawState.positionMs)
put(StateColumns.PARENT_UID, rawState.parentUid?.toString())
put(StateColumns.INDEX, rawState.index)
put(StateColumns.IS_SHUFFLED, rawState.isShuffled)
put(StateColumns.REPEAT_MODE, rawState.repeatMode.intCode)
put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString())
put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs)
put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString())
put(PlaybackStateColumns.INDEX, rawPlaybackState.index)
put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode)
}
insert(TABLE_STATE, null, stateData)
@ -187,47 +210,54 @@ class PlaybackStateDatabase private constructor(context: Context) :
}
}
private fun writeQueue(queue: List<Song>?) {
writableDatabase.writeList(queue ?: listOf(), TABLE_QUEUE) { i, song ->
private fun writeRawQueueState(rawQueueState: RawQueueState?) {
writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song ->
ContentValues(2).apply {
put(BaseColumns._ID, i)
put(QueueColumns.SONG_UID, song.uid.toString())
put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString())
}
}
val combinedMapping =
rawQueueState?.run {
if (shuffledMapping.isNotEmpty()) {
orderedMapping.zip(shuffledMapping)
} else {
orderedMapping.map { Pair(it, null) }
}
}
writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair ->
ContentValues(3).apply {
put(BaseColumns._ID, i)
put(QueueMappingColumns.ORDERED_INDEX, pair.first)
put(QueueMappingColumns.SHUFFLED_INDEX, pair.second)
}
}
}
/**
* A condensed representation of the playback state that can be persisted.
* @param index The position of the currently playing item in the queue. Can be -1 if the
* persisted index no longer exists.
* @param queue The [Song] queue.
* @param parent The [MusicParent] item currently being played from
* @param parent The [MusicParent] item currently being played from.
* @param queueState The [Queue.SavedState]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
* @param isShuffled Whether the queue is shuffled or not.
*/
data class SavedState(
val index: Int,
val queue: List<Song>,
val parent: MusicParent?,
val queueState: Queue.SavedState,
val positionMs: Long,
val repeatMode: RepeatMode,
val isShuffled: Boolean
)
/**
* A lower-level form of [SavedState] that contains additional information to create a more
* reliable restoration process.
*/
private data class RawState(
/** @see SavedState.index */
/** A lower-level form of [SavedState] that contains individual field-based information. */
private data class RawPlaybackState(
/** @see Queue.SavedState.index */
val index: Int,
/** @see SavedState.positionMs */
val positionMs: Long,
/** @see SavedState.repeatMode */
val repeatMode: RepeatMode,
/** @see SavedState.isShuffled */
val isShuffled: Boolean,
/**
* The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be
* used to restore the currently playing item in the queue if the index mapping changed.
@ -237,33 +267,50 @@ class PlaybackStateDatabase private constructor(context: Context) :
val parentUid: Music.UID?
)
/** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */
private data class RawQueueState(
/** @see Queue.SavedState.heap */
val heap: List<Song?>,
/** @see Queue.SavedState.orderedMapping */
val orderedMapping: List<Int>,
/** @see Queue.SavedState.shuffledMapping */
val shuffledMapping: List<Int>
)
/** Defines the columns used in the playback state table. */
private object StateColumns {
/** @see RawState.index */
private object PlaybackStateColumns {
/** @see RawPlaybackState.index */
const val INDEX = "queue_index"
/** @see RawState.positionMs */
/** @see RawPlaybackState.positionMs */
const val POSITION = "position"
/** @see RawState.isShuffled */
const val IS_SHUFFLED = "is_shuffling"
/** @see RawState.repeatMode */
/** @see RawPlaybackState.repeatMode */
const val REPEAT_MODE = "repeat_mode"
/** @see RawState.songUid */
/** @see RawPlaybackState.songUid */
const val SONG_UID = "song_uid"
/** @see RawState.parentUid */
/** @see RawPlaybackState.parentUid */
const val PARENT_UID = "parent"
}
/** Defines the columns used in the queue table. */
private object QueueColumns {
/** Defines the columns used in the queue heap table. */
private object QueueHeapColumns {
/** @see Music.UID */
const val SONG_UID = "song_uid"
}
/** Defines the columns used in the queue mapping table. */
private object QueueMappingColumns {
/** @see Queue.SavedState.orderedMapping */
const val ORDERED_INDEX = "ordered_index"
/** @see Queue.SavedState.shuffledMapping */
const val SHUFFLED_INDEX = "shuffled_index"
}
companion object {
private const val DB_NAME = "auxio_playback_state.db"
private const val DB_VERSION = 8
private const val DB_VERSION = 9
private const val TABLE_STATE = "playback_state"
private const val TABLE_QUEUE = "queue"
private const val TABLE_QUEUE_HEAP = "queue_heap"
private const val TABLE_QUEUE_MAPPINGS = "queue_mapping"
@Volatile private var INSTANCE: PlaybackStateDatabase? = null

View file

@ -17,21 +17,17 @@
package org.oxycblt.auxio.playback.state
import kotlin.math.max
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Core playback state controller class.
@ -59,22 +55,13 @@ class PlaybackStateManager private constructor() {
@Volatile private var pendingAction: InternalPlayer.Action? = null
@Volatile private var isInitialized = false
/** The currently playing [Song]. Null if nothing is playing. */
val song
get() = queue.getOrNull(index)
/** The current [Queue]. */
val queue = Queue()
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile
var parent: MusicParent? = null
var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing.
private set
@Volatile private var _queue = mutableListOf<Song>()
/** The current queue. */
val queue
get() = _queue
/** The position of the currently playing item in the queue. */
@Volatile
var index = -1
private set
/** The current [InternalPlayer] state. */
@Volatile
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
@ -86,13 +73,8 @@ class PlaybackStateManager private constructor() {
field = value
notifyRepeatModeChanged()
}
/** Whether the queue is shuffled. */
@Volatile
var isShuffled = false
private set
/**
* The current audio session ID of the internal player. Null if no [InternalPlayer] is
* available.
* The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable.
*/
val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
@ -106,9 +88,8 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun addListener(listener: Listener) {
if (isInitialized) {
listener.onNewPlayback(index, queue, parent)
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onShuffledChanged(isShuffled)
listener.onStateChanged(playerState)
}
@ -116,7 +97,7 @@ class PlaybackStateManager private constructor() {
}
/**
* Remove a [Listener] from this instance, preventing it from recieving any further updates.
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
* the first place.
* @see Listener
@ -135,13 +116,13 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer != null) {
if (this.internalPlayer != null) {
logW("Internal player is already registered")
return
}
if (isInitialized) {
internalPlayer.loadSong(song, playerState.isPlaying)
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
@ -160,7 +141,7 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
if (this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
@ -173,29 +154,20 @@ class PlaybackStateManager private constructor() {
/**
* Start new playback.
* @param song A particular [Song] to play, or null to play the first [Song] in the new queue.
* @param parent The [MusicParent] to play from, or null if to play from the entire
* [MusicStore.Library].
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue. Defaults to the "Remember shuffle"
* configuration.
* @param queue The queue of [Song]s to play from.
* @param parent The [MusicParent] to play from, or null if to play from an non-specific
* collection of "All [Song]s".
* @param shuffled Whether to shuffle or not.
*/
@Synchronized
fun play(
song: Song?,
parent: MusicParent?,
settings: Settings,
shuffled: Boolean = settings.keepShuffle && isShuffled
) {
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return
val library = musicStore.library ?: return
// Setup parent and queue
// Set up parent and queue
this.parent = parent
_queue = (parent?.songs ?: library.songs).toMutableList()
orderQueue(settings, shuffled, song)
this.queue.start(song, queue, shuffled)
// Notify components of changes
notifyNewPlayback()
notifyShuffledChanged()
internalPlayer.loadSong(this.song, true)
internalPlayer.loadSong(this.queue.currentSong, true)
// Played something, so we are initialized now
isInitialized = true
}
@ -209,13 +181,13 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun next() {
val internalPlayer = internalPlayer ?: return
// Increment the index, if it cannot be incremented any further, then
// repeat and pause/resume playback depending on the setting
if (index < _queue.lastIndex) {
gotoImpl(internalPlayer, index + 1, true)
} else {
gotoImpl(internalPlayer, 0, repeatMode == RepeatMode.ALL)
var play = true
if (!queue.goto(queue.index + 1)) {
queue.goto(0)
play = false
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, play)
}
/**
@ -231,7 +203,11 @@ class PlaybackStateManager private constructor() {
rewind()
setPlaying(true)
} else {
gotoImpl(internalPlayer, max(index - 1, 0), true)
if (!queue.goto(queue.index - 1)) {
queue.goto(0)
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
@ -242,24 +218,17 @@ class PlaybackStateManager private constructor() {
@Synchronized
fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return
gotoImpl(internalPlayer, index, true)
}
private fun gotoImpl(internalPlayer: InternalPlayer, idx: Int, play: Boolean) {
index = idx
notifyIndexMoved()
internalPlayer.loadSong(song, play)
if (queue.goto(index)) {
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
/**
* Add a [Song] to the top of the queue.
* @param song The [Song] to add.
*/
@Synchronized
fun playNext(song: Song) {
_queue.add(index + 1, song)
notifyQueueChanged()
}
@Synchronized fun playNext(song: Song) = playNext(listOf(song))
/**
* Add [Song]s to the top of the queue.
@ -267,19 +236,24 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun playNext(songs: List<Song>) {
_queue.addAll(index + 1, songs)
notifyQueueChanged()
val internalPlayer = internalPlayer ?: return
when (queue.playNext(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
/**
* Add a [Song] to the end of the queue.
* @param song The [Song] to add.
*/
@Synchronized
fun addToQueue(song: Song) {
_queue.add(song)
notifyQueueChanged()
}
@Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song))
/**
* Add [Song]s to the end of the queue.
@ -287,82 +261,53 @@ class PlaybackStateManager private constructor() {
*/
@Synchronized
fun addToQueue(songs: List<Song>) {
_queue.addAll(songs)
notifyQueueChanged()
val internalPlayer = internalPlayer ?: return
when (queue.addToQueue(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
/**
* Move a [Song] in the queue.
* @param from The position of the [Song] to move in the queue.
* @param to The destination position in the queue.
* @param src The position of the [Song] to move in the queue.
* @param dst The destination position in the queue.
*/
@Synchronized
fun moveQueueItem(from: Int, to: Int) {
logD("Moving item $from to position $to")
_queue.add(to, _queue.removeAt(from))
notifyQueueChanged()
fun moveQueueItem(src: Int, dst: Int) {
logD("Moving item $src to position $dst")
notifyQueueChanged(queue.move(src, dst))
}
/**
* Remove a [Song] from the queue.
* @param index The position of the [Song] to remove in the queue.
* @param at The position of the [Song] to remove in the queue.
*/
@Synchronized
fun removeQueueItem(index: Int) {
logD("Removing item ${_queue[index].rawName}")
_queue.removeAt(index)
notifyQueueChanged()
fun removeQueueItem(at: Int) {
val internalPlayer = internalPlayer ?: return
logD("Removing item at $at")
val change = queue.remove(at)
if (change == Queue.ChangeResult.SONG) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
}
notifyQueueChanged(change)
}
/**
* (Re)shuffle or (Re)order this instance.
* @param shuffled Whether to shuffle the queue or not.
* @param settings [Settings] required to configure the queue.
*/
@Synchronized
fun reshuffle(shuffled: Boolean, settings: Settings) {
val song = song ?: return
orderQueue(settings, shuffled, song)
notifyQueueReworked()
notifyShuffledChanged()
}
/**
* Re-configure the queue.
* @param settings [Settings] required to configure the queue.
* @param shuffled Whether to shuffle the queue or not.
* @param keep the [Song] to start at in the new queue, or null if not specified.
*/
private fun orderQueue(settings: Settings, shuffled: Boolean, keep: Song?) {
val newIndex: Int
if (shuffled) {
// Shuffling queue, randomize the current song list and move the Song to play
// to the start.
_queue.shuffle()
if (keep != null) {
_queue.add(0, _queue.removeAt(_queue.indexOf(keep)))
}
newIndex = 0
} else {
// Ordering queue, re-sort it using the analogous parent sort configuration and
// then jump to the Song to play.
// TODO: Rework queue system to avoid having to do this
val sort =
parent.let { parent ->
when (parent) {
null -> settings.libSongSort
is Album -> settings.detailAlbumSort
is Artist -> settings.detailArtistSort
is Genre -> settings.detailGenreSort
}
}
sort.songsInPlace(_queue)
newIndex = keep?.let(_queue::indexOf) ?: 0
}
_queue = queue
index = newIndex
isShuffled = shuffled
fun reorder(shuffled: Boolean) {
queue.reorder(shuffled)
notifyQueueReordered()
}
// --- INTERNAL PLAYER FUNCTIONS ---
@ -379,7 +324,7 @@ class PlaybackStateManager private constructor() {
return
}
val newState = internalPlayer.getState(song?.durationMs ?: 0)
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
@ -443,7 +388,7 @@ class PlaybackStateManager private constructor() {
/**
* Restore the previously saved state (if any) and apply it to the playback state.
* @param database The [PlaybackStateDatabase] to load from.
* @param force Whether to force a restore regardless of the current state.
* @param force Whether to do a restore regardless of any prior playback state.
* @return If the state was restored, false otherwise.
*/
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
@ -469,22 +414,15 @@ class PlaybackStateManager private constructor() {
// State could have changed while we were loading, so check if we were initialized
// now before applying the state.
if (state != null && (!isInitialized || force)) {
index = state.index
parent = state.parent
_queue = state.queue.toMutableList()
queue.applySavedState(state.queueState)
repeatMode = state.repeatMode
isShuffled = state.isShuffled
notifyNewPlayback()
notifyRepeatModeChanged()
notifyShuffledChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(song, false)
internalPlayer.loadSong(queue.currentSong, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true
true
} else {
false
@ -499,17 +437,16 @@ class PlaybackStateManager private constructor() {
*/
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB")
// Create the saved state from the current playback state.
val state =
synchronized(this) {
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = playerState.calculateElapsedPositionMs(),
isShuffled = isShuffled,
repeatMode = repeatMode)
queue.toSavedState()?.let {
PlaybackStateDatabase.SavedState(
parent = parent,
queueState = it,
positionMs = playerState.calculateElapsedPositionMs(),
repeatMode = repeatMode)
}
}
return try {
withContext(Dispatchers.IO) { database.write(state) }
@ -538,11 +475,11 @@ class PlaybackStateManager private constructor() {
}
/**
* Update the playback state to align with a new [MusicStore.Library].
* @param newLibrary The new [MusicStore.Library] that was recently loaded.
* Update the playback state to align with a new [Library].
* @param newLibrary The new [Library] that was recently loaded.
*/
@Synchronized
fun sanitize(newLibrary: MusicStore.Library) {
fun sanitize(newLibrary: Library) {
if (!isInitialized) {
// Nothing playing, nothing to do.
logD("Not initialized, no need to sanitize")
@ -566,12 +503,9 @@ class PlaybackStateManager private constructor() {
}
}
// Sanitize queue. Make sure we re-align the index to point to the previously playing
// Song in the queue queue.
val oldSongUid = song?.uid
_queue = _queue.mapNotNullTo(mutableListOf()) { newLibrary.sanitize(it) }
while (song?.uid != oldSongUid && index > -1) {
index--
// Sanitize the queue.
queue.toSavedState()?.let { state ->
queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) })
}
notifyNewPlayback()
@ -579,8 +513,8 @@ class PlaybackStateManager private constructor() {
val oldPosition = playerState.calculateElapsedPositionMs()
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
internalPlayer.loadSong(song, false)
if (index > -1) {
internalPlayer.loadSong(queue.currentSong, false)
if (queue.currentSong != null) {
// Internal player may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition)
}
@ -590,25 +524,25 @@ class PlaybackStateManager private constructor() {
private fun notifyIndexMoved() {
for (callback in listeners) {
callback.onIndexMoved(index)
callback.onIndexMoved(queue)
}
}
private fun notifyQueueChanged() {
private fun notifyQueueChanged(change: Queue.ChangeResult) {
for (callback in listeners) {
callback.onQueueChanged(queue)
callback.onQueueChanged(queue, change)
}
}
private fun notifyQueueReworked() {
private fun notifyQueueReordered() {
for (callback in listeners) {
callback.onQueueReworked(index, queue)
callback.onQueueReordered(queue)
}
}
private fun notifyNewPlayback() {
for (callback in listeners) {
callback.onNewPlayback(index, queue, parent)
callback.onNewPlayback(queue, parent)
}
}
@ -624,12 +558,6 @@ class PlaybackStateManager private constructor() {
}
}
private fun notifyShuffledChanged() {
for (callback in listeners) {
callback.onShuffledChanged(isShuffled)
}
}
/**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
* [PlaybackStateManager] using [addListener], remove them on destruction with [removeListener].
@ -638,30 +566,30 @@ class PlaybackStateManager private constructor() {
/**
* Called when the position of the currently playing item has changed, changing the current
* [Song], but no other queue attribute has changed.
* @param index The new position in the queue.
* @param queue The new [Queue].
*/
fun onIndexMoved(index: Int) {}
fun onIndexMoved(queue: Queue) {}
/**
* Called when the queue changed in a trivial manner, such as a move.
* @param queue The new queue.
* Called when the [Queue] changed in a manner outlined by the given [Queue.ChangeResult].
* @param queue The new [Queue].
* @param change The type of [Queue.ChangeResult] that occurred.
*/
fun onQueueChanged(queue: List<Song>) {}
fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {}
/**
* Called when the queue has changed in a non-trivial manner (such as re-shuffling), but the
* currently playing [Song] has not.
* @param index The new position in the queue.
* Called when the [Queue] has changed in a non-trivial manner (such as re-shuffling), but
* the currently playing [Song] has not.
* @param queue The new [Queue].
*/
fun onQueueReworked(index: Int, queue: List<Song>) {}
fun onQueueReordered(queue: Queue) {}
/**
* Called when a new playback configuration was created.
* @param index The new position in the queue.
* @param queue The new queue.
* @param queue The new [Queue].
* @param parent The new [MusicParent] being played from, or null if playing from all songs.
*/
fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {}
fun onNewPlayback(queue: Queue, parent: MusicParent?) {}
/**
* Called when the state of the [InternalPlayer] changes.
@ -674,13 +602,6 @@ class PlaybackStateManager private constructor() {
* @param repeatMode The new [RepeatMode].
*/
fun onRepeatChanged(repeatMode: RepeatMode) {}
/**
* Called when the queue's shuffle state changes. Handling the queue change itself should
* occur in [onQueueReworked],
* @param isShuffled Whether the queue is shuffled.
*/
fun onShuffledChanged(isShuffled: Boolean) {}
}
companion object {

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() {
override fun onReceive(context: Context, intent: Intent) {
val playbackManager = PlaybackStateManager.getInstance()
if (playbackManager.song != null) {
if (playbackManager.queue.currentSong != null) {
// We have a song, so we can assume that the service will start a foreground state.
// At least, I hope. Again, *this is why we don't do this*. I cannot describe how
// stupid this is with the state of foreground services on modern android. One

View file

@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback.system
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
@ -31,13 +30,15 @@ import androidx.media.session.MediaButtonReceiver
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.Queue
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
/**
@ -50,7 +51,8 @@ import org.oxycblt.auxio.util.logD
class MediaSessionComponent(private val context: Context, private val listener: Listener) :
MediaSessionCompat.Callback(),
PlaybackStateManager.Listener,
SharedPreferences.OnSharedPreferenceChangeListener {
ImageSettings.Listener,
PlaybackSettings.Listener {
private val mediaSession =
MediaSessionCompat(context, context.packageName).apply {
isActive = true
@ -58,13 +60,14 @@ class MediaSessionComponent(private val context: Context, private val listener:
}
private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context)
private val playbackSettings = PlaybackSettings.from(context)
private val notification = NotificationComponent(context, mediaSession.sessionToken)
private val provider = BitmapProvider(context)
init {
playbackManager.addListener(this)
playbackSettings.registerListener(this)
mediaSession.setCallback(this)
}
@ -82,7 +85,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
*/
fun release() {
provider.release()
settings.removeListener(this)
playbackSettings.unregisterListener(this)
playbackManager.removeListener(this)
mediaSession.apply {
isActive = false
@ -92,22 +95,38 @@ class MediaSessionComponent(private val context: Context, private val listener:
// --- PLAYBACKSTATEMANAGER OVERRIDES ---
override fun onIndexMoved(index: Int) {
updateMediaMetadata(playbackManager.song, playbackManager.parent)
override fun onIndexMoved(queue: Queue) {
updateMediaMetadata(queue.currentSong, playbackManager.parent)
invalidateSessionState()
}
override fun onQueueChanged(queue: List<Song>) {
override fun onQueueChanged(queue: Queue, change: Queue.ChangeResult) {
updateQueue(queue)
when (change) {
// Nothing special to do with mapping changes.
Queue.ChangeResult.MAPPING -> {}
// Index changed, ensure playback state's index changes.
Queue.ChangeResult.INDEX -> invalidateSessionState()
// Song changed, ensure metadata changes.
Queue.ChangeResult.SONG ->
updateMediaMetadata(queue.currentSong, playbackManager.parent)
}
}
override fun onQueueReworked(index: Int, queue: List<Song>) {
override fun onQueueReordered(queue: Queue) {
updateQueue(queue)
invalidateSessionState()
mediaSession.setShuffleMode(
if (queue.isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
updateMediaMetadata(playbackManager.song, parent)
override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
updateMediaMetadata(queue.currentSong, parent)
updateQueue(queue)
invalidateSessionState()
}
@ -131,25 +150,16 @@ class MediaSessionComponent(private val context: Context, private val listener:
invalidateSecondaryAction()
}
override fun onShuffledChanged(isShuffled: Boolean) {
mediaSession.setShuffleMode(
if (isShuffled) {
PlaybackStateCompat.SHUFFLE_MODE_ALL
} else {
PlaybackStateCompat.SHUFFLE_MODE_NONE
})
invalidateSecondaryAction()
}
// --- SETTINGS OVERRIDES ---
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
context.getString(R.string.set_key_cover_mode) ->
updateMediaMetadata(playbackManager.song, playbackManager.parent)
context.getString(R.string.set_key_notif_action) -> invalidateSecondaryAction()
}
override fun onCoverModeChanged() {
// Need to reload the metadata cover.
updateMediaMetadata(playbackManager.queue.currentSong, playbackManager.parent)
}
override fun onNotificationActionChanged() {
// Need to re-load the action shown in the notification.
invalidateSecondaryAction()
}
// --- MEDIASESSION OVERRIDES ---
@ -219,16 +229,13 @@ class MediaSessionComponent(private val context: Context, private val listener:
}
override fun onSetShuffleMode(shuffleMode: Int) {
playbackManager.reshuffle(
playbackManager.reorder(
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL ||
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP,
settings)
shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP)
}
override fun onSkipToQueueItem(id: Long) {
if (id in playbackManager.queue.indices) {
playbackManager.goto(id.toInt())
}
playbackManager.goto(id.toInt())
}
override fun onCustomAction(action: String?, extras: Bundle?) {
@ -318,9 +325,9 @@ class MediaSessionComponent(private val context: Context, private val listener:
* Upload a new queue to the [MediaSessionCompat].
* @param queue The current queue to upload.
*/
private fun updateQueue(queue: List<Song>) {
private fun updateQueue(queue: Queue) {
val queueItems =
queue.mapIndexed { i, song ->
queue.resolve().mapIndexed { i, song ->
val description =
MediaDescriptionCompat.Builder()
// Media ID should not be the item index but rather the UID,
@ -350,18 +357,18 @@ class MediaSessionComponent(private val context: Context, private val listener:
.intoPlaybackState(PlaybackStateCompat.Builder())
.setActions(ACTIONS)
// Active queue ID corresponds to the indices we populated prior, use them here.
.setActiveQueueItemId(playbackManager.index.toLong())
.setActiveQueueItemId(playbackManager.queue.index.toLong())
// Android 13+ relies on custom actions in the notification.
// Add the secondary action (either repeat/shuffle depending on the configuration)
val secondaryAction =
when (settings.playbackNotificationAction) {
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE ->
PlaybackStateCompat.CustomAction.Builder(
PlaybackService.ACTION_INVERT_SHUFFLE,
context.getString(R.string.desc_shuffle),
if (playbackManager.isShuffled) {
if (playbackManager.queue.isShuffled) {
R.drawable.ic_shuffle_on_24
} else {
R.drawable.ic_shuffle_off_24
@ -390,8 +397,8 @@ class MediaSessionComponent(private val context: Context, private val listener:
private fun invalidateSecondaryAction() {
invalidateSessionState()
when (settings.playbackNotificationAction) {
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.isShuffled)
when (playbackSettings.notificationAction) {
ActionMode.SHUFFLE -> notification.updateShuffled(playbackManager.queue.isShuffled)
else -> notification.updateRepeatMode(playbackManager.repeatMode)
}

View file

@ -43,15 +43,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.widgets.WidgetComponent
import org.oxycblt.auxio.widgets.WidgetProvider
@ -91,7 +93,8 @@ class PlaybackService :
// Managers
private val playbackManager = PlaybackStateManager.getInstance()
private val musicStore = MusicStore.getInstance()
private lateinit var settings: Settings
private lateinit var musicSettings: MusicSettings
private lateinit var playbackSettings: PlaybackSettings
// State
private lateinit var foregroundManager: ForegroundManager
@ -142,7 +145,8 @@ class PlaybackService :
.also { it.addListener(this) }
replayGainProcessor.addToListeners(player)
// Initialize the core service components
settings = Settings(this)
musicSettings = MusicSettings.from(this)
playbackSettings = PlaybackSettings.from(this)
foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
@ -212,7 +216,7 @@ class PlaybackService :
get() = player.audioSessionId
override val shouldRewindWithPrev: Boolean
get() = settings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
get() = playbackSettings.rewindWithPrev && player.currentPosition > REWIND_THRESHOLD
override fun getState(durationMs: Long) =
InternalPlayer.State.from(
@ -285,7 +289,7 @@ class PlaybackService :
if (playbackManager.repeatMode == RepeatMode.TRACK) {
playbackManager.rewind()
// May be configured to pause when we repeat a track.
if (settings.pauseOnRepeat) {
if (playbackSettings.pauseOnRepeat) {
playbackManager.setPlaying(false)
}
} else {
@ -302,7 +306,7 @@ class PlaybackService :
// --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library != null) {
// We now have a library, see if we have anything we need to do.
playbackManager.requestAction(this)
@ -351,12 +355,16 @@ class PlaybackService :
}
// Shuffle all -> Start new playback from all songs
is InternalPlayer.Action.ShuffleAll -> {
playbackManager.play(null, null, settings, true)
playbackManager.play(null, null, musicSettings.songSort.songs(library.songs), true)
}
// Open -> Try to find the Song for the given file and then play it from all songs
is InternalPlayer.Action.Open -> {
library.findSongForUri(application, action.uri)?.let { song ->
playbackManager.play(song, null, settings)
playbackManager.play(
song,
null,
musicSettings.songSort.songs(library.songs),
playbackManager.queue.isShuffled && playbackSettings.keepShuffle)
}
}
}
@ -411,8 +419,7 @@ class PlaybackService :
playbackManager.setPlaying(!playbackManager.playerState.isPlaying)
ACTION_INC_REPEAT_MODE ->
playbackManager.repeatMode = playbackManager.repeatMode.increment()
ACTION_INVERT_SHUFFLE ->
playbackManager.reshuffle(!playbackManager.isShuffled, settings)
ACTION_INVERT_SHUFFLE -> playbackManager.reorder(!playbackManager.queue.isShuffled)
ACTION_SKIP_PREV -> playbackManager.prev()
ACTION_SKIP_NEXT -> playbackManager.next()
ACTION_EXIT -> {
@ -427,8 +434,8 @@ class PlaybackService :
// ACTION_HEADSET_PLUG will fire when this BroadcastReciever is initially attached,
// which would result in unexpected playback. Work around it by dropping the first
// call to this function, which should come from that Intent.
if (settings.headsetAutoplay &&
playbackManager.song != null &&
if (playbackSettings.headsetAutoplay &&
playbackManager.queue.currentSong != null &&
initialHeadsetPlugEventHandled) {
logD("Device connected, resuming")
playbackManager.setPlaying(true)
@ -436,7 +443,7 @@ class PlaybackService :
}
private fun pauseFromHeadsetPlug() {
if (playbackManager.song != null) {
if (playbackManager.queue.currentSong != null) {
logD("Device disconnected, pausing")
playbackManager.setPlaying(false)
}

View file

@ -18,29 +18,28 @@
package org.oxycblt.auxio.search
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.logD
/**
* An adapter that displays search results.
* @param listener An [SelectableListListener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
private val differ = AsyncListDiffer(this, DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
class SearchAdapter(private val listener: SelectableListListener<Music>) :
SelectionIndicatorAdapter<Item, BasicListInstructions, RecyclerView.ViewHolder>(
ListDiffer.Async(DIFF_CALLBACK)),
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
when (getItem(position)) {
is Song -> SongViewHolder.VIEW_TYPE
is Album -> AlbumViewHolder.VIEW_TYPE
is Artist -> ArtistViewHolder.VIEW_TYPE
@ -60,7 +59,8 @@ class SearchAdapter(private val listener: SelectableListListener) :
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = differ.currentList[position]) {
logD(position)
when (val item = getItem(position)) {
is Song -> (holder as SongViewHolder).bind(item, listener)
is Album -> (holder as AlbumViewHolder).bind(item, listener)
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
@ -69,22 +69,21 @@ class SearchAdapter(private val listener: SelectableListListener) :
}
}
override fun isItemFullWidth(position: Int) = differ.currentList[position] is Header
override fun isItemFullWidth(position: Int) = getItem(position) is Header
/**
* Asynchronously update the list with new items. Assumes that the list only contains supported
* data..
* @param newList The new [Item]s for the adapter to display.
* @param callback A block called when the asynchronous update is completed.
* Make sure that the top header has a correctly configured divider visibility. This would
* normally be automatically done by the differ, but that results in a strange animation.
*/
fun submitList(newList: List<Item>, callback: () -> Unit) {
differ.submitList(newList, callback)
fun pokeDividers() {
notifyItemChanged(0, PAYLOAD_UPDATE_DIVIDER)
}
private companion object {
val PAYLOAD_UPDATE_DIVIDER = 102249124
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item) =
when {
oldItem is Song && newItem is Song ->

View file

@ -31,14 +31,13 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.*
/**
@ -50,7 +49,7 @@ import org.oxycblt.auxio.util.*
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SearchFragment : ListFragment<FragmentSearchBinding>() {
class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
private val searchModel: SearchViewModel by androidViewModels()
private val searchAdapter = SearchAdapter(this)
private var imm: InputMethodManager? = null
@ -134,26 +133,19 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
return false
}
override fun onRealClick(music: Music) {
when (music) {
is Song ->
when (Settings(requireContext()).libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
is MusicParent -> navModel.exploreNavigateTo(music)
override fun onRealClick(item: Music) {
when (item) {
is MusicParent -> navModel.exploreNavigateTo(item)
is Song -> playbackModel.playFrom(item, searchModel.playbackMode)
}
}
override fun onOpenMenu(item: Item, anchor: View) {
override fun onOpenMenu(item: Music, anchor: View) {
when (item) {
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
is Album -> openMusicMenu(anchor, R.menu.menu_album_actions, item)
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
is Genre -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
else -> logW("Unexpected datatype when opening menu: ${item::class.java}")
}
}
@ -162,16 +154,17 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
// Don't show the RecyclerView (and it's stray overscroll effects) when there
// are no results.
binding.searchRecycler.isInvisible = results.isEmpty()
searchAdapter.submitList(results.toMutableList()) {
searchAdapter.submitList(results.toMutableList(), BasicListInstructions.DIFF) {
// I would make it so that the position is only scrolled back to the top when
// the query actually changes instead of once every re-creation event, but sadly
// that doesn't seem possible.
binding.searchRecycler.scrollToPosition(0)
searchAdapter.pokeDividers()
}
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
searchAdapter.setPlayingItem(parent ?: song, isPlaying)
searchAdapter.setPlaying(parent ?: song, isPlaying)
}
private fun handleNavigation(item: Music?) {
@ -189,7 +182,7 @@ class SearchFragment : ListFragment<FragmentSearchBinding>() {
}
private fun updateSelection(selected: List<Music>) {
searchAdapter.setSelectedItems(selected)
searchAdapter.setSelected(selected.toSet())
if (requireBinding().searchSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
// Make selection of obscured items easier by hiding the keyboard.

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.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
@ -45,7 +45,8 @@ import org.oxycblt.auxio.util.logD
class SearchViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(context)
private val searchSettings = SearchSettings.from(application)
private val playbackSettings = PlaybackSettings.from(application)
private var lastQuery: String? = null
private var currentSearchJob: Job? = null
@ -54,6 +55,10 @@ class SearchViewModel(application: Application) :
val searchResults: StateFlow<List<Item>>
get() = _searchResults
/** The [MusicMode] to use when playing a [Song] from the UI. */
val playbackMode: MusicMode
get() = playbackSettings.inListPlaybackMode
init {
musicStore.addListener(this)
}
@ -63,7 +68,7 @@ class SearchViewModel(application: Application) :
musicStore.removeListener(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
override fun onLibraryChanged(library: Library?) {
if (library != null) {
// Make sure our query is up to date with the music library.
search(lastQuery)
@ -96,9 +101,9 @@ class SearchViewModel(application: Application) :
}
}
private fun searchImpl(library: MusicStore.Library, query: String): List<Item> {
private fun searchImpl(library: Library, query: String): List<Item> {
val sort = Sort(Sort.Mode.ByName, true)
val filterMode = settings.searchFilterMode
val filterMode = searchSettings.searchFilterMode
val results = mutableListOf<Item>()
// Note: A null filter mode maps to the "All" filter option, hence the check.
@ -183,7 +188,7 @@ class SearchViewModel(application: Application) :
*/
@IdRes
fun getFilterOptionId() =
when (settings.searchFilterMode) {
when (searchSettings.searchFilterMode) {
MusicMode.SONGS -> R.id.option_filter_songs
MusicMode.ALBUMS -> R.id.option_filter_albums
MusicMode.ARTISTS -> R.id.option_filter_artists
@ -208,7 +213,7 @@ class SearchViewModel(application: Application) :
else -> error("Invalid option ID provided")
}
logD("Updating filter mode to $newFilterMode")
settings.searchFilterMode = newFilterMode
searchSettings.searchFilterMode = newFilterMode
search(lastQuery)
}

View file

@ -123,7 +123,7 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
if (pkgName == "android") {
// No default browser [Must open app chooser, may not be supported]
openAppChooser(browserIntent)
} else {
} else
try {
browserIntent.setPackage(pkgName)
startActivity(browserIntent)
@ -132,7 +132,6 @@ class AboutFragment : ViewBindingFragment<FragmentAboutBinding>() {
browserIntent.setPackage(null)
openAppChooser(browserIntent)
}
}
} else {
// No app installed to open the link
context.showToast(R.string.err_no_app)

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
* it under the terms of the GNU General Public License as published by
@ -19,446 +19,80 @@ package org.oxycblt.auxio.settings
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit
import androidx.annotation.StringRes
import androidx.preference.PreferenceManager
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.MusicDirectories
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
import org.oxycblt.auxio.ui.accent.Accent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A [SharedPreferences] wrapper providing type-safe interfaces to all of the app's settings. Member
* mutability is dependent on how they are used in app. Immutable members are often only modified by
* the preferences view, while mutable members are modified elsewhere.
* Abstract user configuration information. This interface has no functionality whatsoever. Concrete
* implementations should be preferred instead.
* @author Alexander Capehart (OxygenCobalt)
*/
class Settings(private val context: Context) {
private val inner = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
interface Settings<L> {
/**
* Migrate any settings from an old version into their modern counterparts. This can cause data
* loss depending on the feasibility of a migration.
* Migrate any settings fields from older versions into their new counterparts.
* @throws NotImplementedError If there is nothing to migrate.
*/
fun migrate() {
if (inner.contains(OldKeys.KEY_ACCENT3)) {
logD("Migrating ${OldKeys.KEY_ACCENT3}")
var accent = inner.getInt(OldKeys.KEY_ACCENT3, 5)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Accents were previously frozen as soon as the OS was updated to android twelve,
// as dynamic colors were enabled by default. This is no longer the case, so we need
// to re-update the setting to dynamic colors here.
accent = 16
}
inner.edit {
putInt(context.getString(R.string.set_key_accent), accent)
remove(OldKeys.KEY_ACCENT3)
apply()
}
}
if (inner.contains(OldKeys.KEY_SHOW_COVERS) || inner.contains(OldKeys.KEY_QUALITY_COVERS)) {
logD("Migrating cover settings")
val mode =
when {
!inner.getBoolean(OldKeys.KEY_SHOW_COVERS, true) -> CoverMode.OFF
!inner.getBoolean(OldKeys.KEY_QUALITY_COVERS, true) -> CoverMode.MEDIA_STORE
else -> CoverMode.QUALITY
}
inner.edit {
putInt(context.getString(R.string.set_key_cover_mode), mode.intCode)
remove(OldKeys.KEY_SHOW_COVERS)
remove(OldKeys.KEY_QUALITY_COVERS)
}
}
if (inner.contains(OldKeys.KEY_ALT_NOTIF_ACTION)) {
logD("Migrating ${OldKeys.KEY_ALT_NOTIF_ACTION}")
val mode =
if (inner.getBoolean(OldKeys.KEY_ALT_NOTIF_ACTION, false)) {
ActionMode.SHUFFLE
} else {
ActionMode.REPEAT
}
inner.edit {
putInt(context.getString(R.string.set_key_notif_action), mode.intCode)
remove(OldKeys.KEY_ALT_NOTIF_ACTION)
apply()
}
}
fun Int.migratePlaybackMode() =
when (this) {
// Convert PlaybackMode into MusicMode
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
else -> null
}
if (inner.contains(OldKeys.KEY_LIB_PLAYBACK_MODE)) {
logD("Migrating ${OldKeys.KEY_LIB_PLAYBACK_MODE}")
val mode =
inner
.getInt(OldKeys.KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS)
.migratePlaybackMode()
?: MusicMode.SONGS
inner.edit {
putInt(context.getString(R.string.set_key_library_song_playback_mode), mode.intCode)
remove(OldKeys.KEY_LIB_PLAYBACK_MODE)
apply()
}
}
if (inner.contains(OldKeys.KEY_DETAIL_PLAYBACK_MODE)) {
logD("Migrating ${OldKeys.KEY_DETAIL_PLAYBACK_MODE}")
val mode =
inner.getInt(OldKeys.KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE).migratePlaybackMode()
inner.edit {
putInt(
context.getString(R.string.set_key_detail_song_playback_mode),
mode?.intCode ?: Int.MIN_VALUE)
remove(OldKeys.KEY_DETAIL_PLAYBACK_MODE)
apply()
}
}
throw NotImplementedError()
}
/**
* Add a [SharedPreferences.OnSharedPreferenceChangeListener] to monitor for settings updates.
* @param listener The [SharedPreferences.OnSharedPreferenceChangeListener] to add.
* Add a listener to monitor for settings updates. Will do nothing if
* @param listener The listener to add.
*/
fun addListener(listener: OnSharedPreferenceChangeListener) {
inner.registerOnSharedPreferenceChangeListener(listener)
}
fun registerListener(listener: L)
/**
* Unregister a [SharedPreferences.OnSharedPreferenceChangeListener], preventing any further
* settings updates from being sent to ti.t
* Unregister a listener, preventing any further settings updates from being sent to it.
* @param listener The listener to unregister, must be the same as the current listener.
*/
fun removeListener(listener: OnSharedPreferenceChangeListener) {
inner.unregisterOnSharedPreferenceChangeListener(listener)
}
// --- VALUES ---
/** The current theme. Represented by the [AppCompatDelegate] constants. */
val theme: Int
get() =
inner.getInt(
context.getString(R.string.set_key_theme),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
/** Whether to use a black background when a dark theme is currently used. */
val useBlackTheme: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_black_theme), false)
/** The current [Accent] (Color Scheme). */
var accent: Accent
get() =
Accent.from(inner.getInt(context.getString(R.string.set_key_accent), Accent.DEFAULT))
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_accent), value.index)
apply()
}
}
/** The tabs to show in the home UI. */
var libTabs: Array<Tab>
get() =
Tab.fromIntCode(
inner.getInt(context.getString(R.string.set_key_lib_tabs), Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_lib_tabs), Tab.toIntCode(value))
apply()
}
}
/** Whether to hide artists considered "collaborators" from the home UI. */
val shouldHideCollaborators: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_hide_collaborators), false)
/** Whether to round additional UI elements that require album covers to be rounded. */
val roundMode: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_round_mode), false)
/** The action to display on the playback bar. */
val playbackBarAction: ActionMode
get() =
ActionMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_bar_action), Int.MIN_VALUE))
?: ActionMode.NEXT
/** The action to display in the playback notification. */
val playbackNotificationAction: ActionMode
get() =
ActionMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_notif_action), Int.MIN_VALUE))
?: ActionMode.REPEAT
/** Whether to start playback when a headset is plugged in. */
val headsetAutoplay: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_headset_autoplay), false)
/** The current ReplayGain configuration. */
val replayGainMode: ReplayGainMode
get() =
ReplayGainMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
?: ReplayGainMode.DYNAMIC
/** The current ReplayGain pre-amp configuration. */
var replayGainPreAmp: ReplayGainPreAmp
get() =
ReplayGainPreAmp(
inner.getFloat(context.getString(R.string.set_key_pre_amp_with), 0f),
inner.getFloat(context.getString(R.string.set_key_pre_amp_without), 0f))
set(value) {
inner.edit {
putFloat(context.getString(R.string.set_key_pre_amp_with), value.with)
putFloat(context.getString(R.string.set_key_pre_amp_without), value.without)
apply()
}
}
/** What MusicParent item to play from when a Song is played from the home view. */
val libPlaybackMode: MusicMode
get() =
MusicMode.fromIntCode(
inner.getInt(
context.getString(R.string.set_key_library_song_playback_mode), Int.MIN_VALUE))
?: MusicMode.SONGS
fun unregisterListener(listener: L)
/**
* What MusicParent item to play from when a Song is played from the detail view. Will be null
* if configured to play from the currently shown item.
* A framework-backed [Settings] implementation.
* @param context [Context] required.
*/
val detailPlaybackMode: MusicMode?
get() =
MusicMode.fromIntCode(
inner.getInt(
context.getString(R.string.set_key_detail_song_playback_mode), Int.MIN_VALUE))
abstract class Real<L>(private val context: Context) :
Settings<L>, SharedPreferences.OnSharedPreferenceChangeListener {
protected val sharedPreferences: SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
/** Whether to keep shuffle on when playing a new Song. */
val keepShuffle: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_keep_shuffle), true)
/** @see [Context.getString] */
protected fun getString(@StringRes stringRes: Int) = context.getString(stringRes)
/** Whether to rewind when the skip previous button is pressed before skipping back. */
val rewindWithPrev: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_rewind_prev), true)
private var listener: L? = null
/** Whether a song should pause after every repeat. */
val pauseOnRepeat: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false)
/** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_observing), false)
/** The strategy used when loading album covers. */
val coverMode: CoverMode
get() =
CoverMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.MEDIA_STORE
/** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_exclude_non_music), true)
/**
* Set the configuration on how to handle particular directories in the music library.
* @param storageManager [StorageManager] required to parse directories.
* @return The [MusicDirectories] configuration.
*/
fun getMusicDirs(storageManager: StorageManager): MusicDirectories {
val dirs =
(inner.getStringSet(context.getString(R.string.set_key_music_dirs), null) ?: emptySet())
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
return MusicDirectories(
dirs, inner.getBoolean(context.getString(R.string.set_key_music_dirs_include), false))
}
/**
* Set the configuration on how to handle particular directories in the music library.
* @param musicDirs The new [MusicDirectories] configuration.
*/
fun setMusicDirs(musicDirs: MusicDirectories) {
inner.edit {
putStringSet(
context.getString(R.string.set_key_music_dirs),
musicDirs.dirs.map(Directory::toDocumentTreeUri).toSet())
putBoolean(
context.getString(R.string.set_key_music_dirs_include), musicDirs.shouldInclude)
apply()
}
}
/**
* A string of characters representing the desired separator characters to denote multi-value
* tags.
*/
var musicSeparators: String?
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use in Regexes and makes it more extendable.
get() =
inner.getString(context.getString(R.string.set_key_separators), null)?.ifEmpty { null }
set(value) {
inner.edit {
putString(context.getString(R.string.set_key_separators), value?.ifEmpty { null })
apply()
override fun registerListener(listener: L) {
if (this.listener == null) {
// Registering a listener when it was null prior, attach the callback.
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
this.listener = listener
}
/** The type of Music the search view is currently filtering to. */
var searchFilterMode: MusicMode?
get() =
MusicMode.fromIntCode(
inner.getInt(context.getString(R.string.set_key_search_filter), Int.MIN_VALUE))
set(value) {
inner.edit {
putInt(
context.getString(R.string.set_key_search_filter),
value?.intCode ?: Int.MIN_VALUE)
apply()
override fun unregisterListener(listener: L) {
if (this.listener !== listener) {
logW("Given listener was not the current listener.")
}
this.listener = null
// No longer have a listener, detach from the preferences instance.
sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
}
/** The Song [Sort] mode used in the Home UI. */
var libSongSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_lib_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_lib_songs_sort), value.intCode)
apply()
}
final override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences,
key: String
) {
onSettingChanged(key, unlikelyToBeNull(listener))
}
/** The Album [Sort] mode used in the Home UI. */
var libAlbumSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_lib_albums_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_lib_albums_sort), value.intCode)
apply()
}
}
/** The Artist [Sort] mode used in the Home UI. */
var libArtistSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_lib_artists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_lib_artists_sort), value.intCode)
apply()
}
}
/** The Genre [Sort] mode used in the Home UI. */
var libGenreSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_lib_genres_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_lib_genres_sort), value.intCode)
apply()
}
}
/** The [Sort] mode used in the Album Detail UI. */
var detailAlbumSort: Sort
get() {
var sort =
Sort.fromIntCode(
inner.getInt(
context.getString(R.string.set_key_detail_album_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDisc, true)
// Correct legacy album sort modes to Disc
if (sort.mode is Sort.Mode.ByName) {
sort = sort.withMode(Sort.Mode.ByDisc)
}
return sort
}
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_detail_album_sort), value.intCode)
apply()
}
}
/** The [Sort] mode used in the Artist Detail UI. */
var detailArtistSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_detail_artist_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDate, false)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_detail_artist_sort), value.intCode)
apply()
}
}
/** The [Sort] mode used in the Genre Detail UI. */
var detailGenreSort: Sort
get() =
Sort.fromIntCode(
inner.getInt(context.getString(R.string.set_key_detail_genre_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
inner.edit {
putInt(context.getString(R.string.set_key_detail_genre_sort), value.intCode)
apply()
}
}
/** Legacy keys that are no longer used, but still have to be migrated. */
private object OldKeys {
const val KEY_ACCENT3 = "auxio_accent"
const val KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION"
const val KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
const val KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
/**
* Called when a setting entry with the given [key] has changed.
* @param key The key of the changed setting.
* @param listener The implementation's listener that updates should be applied to.
*/
protected open fun onSettingChanged(key: String, listener: L) {}
}
}

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